feat: Implement admin-only specialties management interface
Playwright Tests / test (push) Canceled after 0s
Playwright Tests / test (push) Canceled after 0s
- Added MailerAttachmentPolicy for validating email attachments against server rules. - Introduced fis.specialties.js for managing hidden maintenance actions via an admin interface. - Created fis.specialties.scss for styling the specialties management UI. - Added German localization for specialties management in fis.specialties_txt_de.js. - Developed CSS for specialties management in fis.specialties.css. - Implemented server-side stored procedure fds__admin_backupAndClearInvoiceFile for backing up and clearing invoice files. - Created fds__invoice_file_backups table to store backup records of invoice files.
This commit is contained in:
@@ -49,7 +49,7 @@
|
||||
|
||||
## Services & Dependency Injection
|
||||
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces; inject them into `IntranetController` (constructor injection). Do **not** reintroduce static God-classes or pass the whole controller into helpers.
|
||||
- `IComService` (email/SMS via ProcessWeb Mailer API, attachments sent inline as base64; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4).
|
||||
- `IComService` (email/SMS via ProcessWeb Mailer API — contract at **https://processweb.de/api/mailer/fc**, read it and `Fuchs/Docs/Concepts/processweb-mailer-interface.md` before changing it. `ProcessWebComService` is a thin adapter over the OCORE_web `IPWMailerClientService` (`OCORE_web/comm/pwmailer/`, owns the HTTP contract; ADR 0029) with `FuchsMailerClient` overriding `ValidateMessage` for attachment limits; a send only counts as successful when the receipt's `attachmentsProcessed` matches the number sent (ADR 0028); honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4).
|
||||
- Lifetimes: stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; request-scoped DB services (`IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IComService`, `ISystemStatusService`) are scoped. Register in `Program.cs`.
|
||||
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders** (parse + properties). Loading, persistence and PDF generation belong in the services — never `Task.Run(...).Wait()` sync-over-async.
|
||||
- Data access stays SQL-first via OCORE helpers (`getSQLDataSet_async`, `setSQLValue_async`) + stored procedures; no EF Core.
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
## Services & Dependency Injection
|
||||
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers.
|
||||
- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
|
||||
- Services: `IComService` (ProcessWeb Mailer API — contract at **https://processweb.de/api/mailer/fc**, read it and `Fuchs/Docs/Concepts/processweb-mailer-interface.md` before changing it. `ProcessWebComService` is a thin adapter over the OCORE_web `IPWMailerClientService` (`OCORE_web/comm/pwmailer/`, owns the HTTP contract; ADR 0029) with `FuchsMailerClient` overriding `ValidateMessage` for attachment limits; a send only counts as successful when the receipt's `attachmentsProcessed` matches the number sent (ADR 0028); honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
|
||||
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async.
|
||||
- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core).
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
## Services & Dependency Injection
|
||||
- Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers.
|
||||
- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
|
||||
- Services: `IComService` (ProcessWeb Mailer API — contract at **https://processweb.de/api/mailer/fc**, read it and `Fuchs/Docs/Concepts/processweb-mailer-interface.md` before changing it. `ProcessWebComService` is a thin adapter over the OCORE_web `IPWMailerClientService` (`OCORE_web/comm/pwmailer/`, owns the HTTP contract; ADR 0029) with `FuchsMailerClient` overriding `ValidateMessage` for attachment limits; a send only counts as successful when the receipt's `attachmentsProcessed` matches the number sent (ADR 0028); honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`).
|
||||
- `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async.
|
||||
- Data access is SQL-first via OCORE helpers + stored procedures (no EF Core).
|
||||
|
||||
|
||||
@@ -172,4 +172,147 @@ public class AzureBlobStorageServiceTests
|
||||
Assert.Null(result);
|
||||
Assert.True(delta >= 1, "fuchs.blobstorage.uploads.failed counter should have been incremented on a failed upload.");
|
||||
}
|
||||
|
||||
// ── CopyInvoicePdfToBackupAsync ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CopyInvoicePdfToBackupAsync_NoClient_ReturnsNullWithoutThrowing()
|
||||
{
|
||||
var svc = new AzureBlobStorageService(client: null, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
|
||||
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyInvoicePdfToBackupAsync_NoExistingBlob_ReturnsNullWithoutCopying()
|
||||
{
|
||||
var (service, container, blob) = CreateMockedClientChain();
|
||||
blob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(false, Mock.Of<Response>()));
|
||||
|
||||
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf");
|
||||
|
||||
Assert.Null(result);
|
||||
blob.Verify(b => b.StartCopyFromUriAsync(It.IsAny<Uri>(), It.IsAny<BlobCopyFromUriOptions>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
blob.Verify(b => b.DeleteIfExistsAsync(
|
||||
It.IsAny<DeleteSnapshotsOption>(), It.IsAny<BlobRequestConditions>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source blob exists; the backup blob accepts a server-side copy whose operation completes
|
||||
/// immediately. Returns the mocks needed for verification.
|
||||
/// </summary>
|
||||
private static (Mock<BlobServiceClient> service, Mock<BlobClient> sourceBlob, Mock<BlobClient> backupBlob, Mock<CopyFromUriOperation> copy)
|
||||
CreateCopyChain(Uri sourceUri, Uri backupUri)
|
||||
{
|
||||
var (service, container, sourceBlob) = CreateMockedClientChain();
|
||||
sourceBlob.Setup(b => b.Uri).Returns(sourceUri);
|
||||
sourceBlob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(true, Mock.Of<Response>()));
|
||||
|
||||
var copy = new Mock<CopyFromUriOperation>();
|
||||
copy.Setup(c => c.WaitForCompletionAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(3L, Mock.Of<Response>()));
|
||||
|
||||
var backupBlob = new Mock<BlobClient>();
|
||||
backupBlob.Setup(b => b.Uri).Returns(backupUri);
|
||||
backupBlob.Setup(b => b.StartCopyFromUriAsync(It.IsAny<Uri>(), It.IsAny<BlobCopyFromUriOptions>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(copy.Object);
|
||||
container.Setup(c => c.GetBlobClient(It.Is<string>(n => n.StartsWith("INV1/backup_"))))
|
||||
.Returns(backupBlob.Object);
|
||||
|
||||
return (service, sourceBlob, backupBlob, copy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyInvoicePdfToBackupAsync_ExistingBlob_CopiesServerSideUnderBackupNameAndKeepsOriginal()
|
||||
{
|
||||
var sourceUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/Rechnung_INV1.pdf");
|
||||
var backupUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/backup_20260917_120000_Rechnung_INV1.pdf");
|
||||
var (service, sourceBlob, backupBlob, copy) = CreateCopyChain(sourceUri, backupUri);
|
||||
|
||||
long succeeded = 0;
|
||||
using var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (inst, l) =>
|
||||
{
|
||||
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.blobstorage.backupcopies")
|
||||
l.EnableMeasurementEvents(inst);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref succeeded, value));
|
||||
listener.Start();
|
||||
|
||||
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf");
|
||||
|
||||
Assert.Equal(backupUri, result);
|
||||
// The bytes must never pass through the process: server-side copy from the source URI only.
|
||||
backupBlob.Verify(b => b.StartCopyFromUriAsync(sourceUri, It.IsAny<BlobCopyFromUriOptions>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
copy.Verify(c => c.WaitForCompletionAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
sourceBlob.Verify(b => b.DownloadContentAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||
backupBlob.Verify(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
// The original stays: it remains the current archive until the next regeneration overwrites it.
|
||||
sourceBlob.Verify(b => b.DeleteIfExistsAsync(
|
||||
It.IsAny<DeleteSnapshotsOption>(), It.IsAny<BlobRequestConditions>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
Assert.True(succeeded >= 1, "fuchs.blobstorage.backupcopies counter should have been incremented.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyInvoicePdfToBackupAsync_CopyFails_ReturnsNullAndIncrementsFailedCounter()
|
||||
{
|
||||
var sourceUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/Rechnung_INV1.pdf");
|
||||
var backupUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/backup_x_Rechnung_INV1.pdf");
|
||||
var (service, sourceBlob, backupBlob, copy) = CreateCopyChain(sourceUri, backupUri);
|
||||
copy.Setup(c => c.WaitForCompletionAsync(It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException("copy aborted"));
|
||||
|
||||
long failed = 0;
|
||||
using var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (inst, l) =>
|
||||
{
|
||||
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.blobstorage.backupcopies.failed")
|
||||
l.EnableMeasurementEvents(inst);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref failed, value));
|
||||
listener.Start();
|
||||
|
||||
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf");
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.True(failed >= 1, "fuchs.blobstorage.backupcopies.failed counter should have been incremented.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyInvoicePdfToBackupAsync_ClientThrows_ReturnsNullAndIncrementsFailedCounter()
|
||||
{
|
||||
var (service, container, blob) = CreateMockedClientChain();
|
||||
blob.Setup(b => b.ExistsAsync(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.backupcopies.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.CopyInvoicePdfToBackupAsync("INV2", "Rechnung_INV2.pdf");
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.True(delta >= 1, "fuchs.blobstorage.backupcopies.failed counter should have been incremented.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OCORE.web.pwmailer;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the Fuchs override of <see cref="PWMailerClientService.ValidateMessage"/>: the Mailer
|
||||
/// contract's attachment limits (count, per-file and per-message size, forbidden extensions) are
|
||||
/// enforced before any HTTP request, on top of the base rules (required fields, empty file,
|
||||
/// missing extension), and a compliant message still goes out. The transport is a scripted
|
||||
/// handler that fails the test if it is reached when it must not be.
|
||||
/// </summary>
|
||||
public class FuchsMailerClientTests
|
||||
{
|
||||
private sealed class ScriptedHandler : HttpMessageHandler
|
||||
{
|
||||
public int Calls;
|
||||
public string Body = "{\"comStackID\":\"11111111-2222-3333-4444-555555555555\",\"attachmentsReceived\":1,\"attachmentsProcessed\":1}";
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
Calls++;
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(Body) });
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Factory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpMessageHandler _handler;
|
||||
public Factory(HttpMessageHandler handler) => _handler = handler;
|
||||
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
|
||||
}
|
||||
|
||||
private static (FuchsMailerClient client, ScriptedHandler handler) Make(MailerAttachmentLimits? limits = null)
|
||||
{
|
||||
var handler = new ScriptedHandler();
|
||||
var options = Options.Create(new PWMailerClientOptions
|
||||
{
|
||||
BaseUrl = "https://mailer.test/api/mailer", Account = "acct", Token = "tok",
|
||||
ServerId = "11111111-2222-3333-4444-555555555555", RetryDelayMilliseconds = 0
|
||||
});
|
||||
var settings = Options.Create(new ProcessWebComSettings { Attachments = limits ?? new MailerAttachmentLimits() });
|
||||
return (new FuchsMailerClient(options, settings, new Factory(handler), NullLogger<PWMailerClientService>.Instance), handler);
|
||||
}
|
||||
|
||||
private static PWMailerMessage Message(params PWMailerAttachment[] attachments)
|
||||
{
|
||||
var m = new PWMailerMessage { To = "a@example.com", Subject = "Hi", Body = "<p>x</p>", Reference = "R1" };
|
||||
m.Attachments.AddRange(attachments);
|
||||
return m;
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> LimitViolations()
|
||||
{
|
||||
yield return [new PWMailerAttachment[] { new("run.EXE", [1]) }, "forbidden extension (.exe)"];
|
||||
yield return [new PWMailerAttachment[] { new("script.js", [1]) }, "forbidden extension (.js)"];
|
||||
yield return [Enumerable.Range(1, 11).Select(i => new PWMailerAttachment($"f{i}.pdf", [1])).ToArray(), "Too many attachments: 11 (max 10)"];
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(LimitViolations))]
|
||||
public async Task PushAsync_AttachmentViolatesContractLimits_RefusedBeforeSending(PWMailerAttachment[] attachments, string expected)
|
||||
{
|
||||
var (client, handler) = Make();
|
||||
|
||||
var result = await client.PushAsync(Message(attachments));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(0, result.StatusCode);
|
||||
Assert.Equal(0, result.Attempts);
|
||||
Assert.Contains(expected, result.Error);
|
||||
Assert.Equal(0, handler.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushAsync_PerFileAndPerMessageSizeLimits_AreEnforcedFromSettings()
|
||||
{
|
||||
var (client, handler) = Make(new MailerAttachmentLimits { MaxFileBytes = 100, MaxMessageBytes = 150, MaxCount = 10 });
|
||||
|
||||
var tooBig = await client.PushAsync(Message(new PWMailerAttachment("a.pdf", new byte[101])));
|
||||
Assert.Contains("'a.pdf' is 101 bytes (max 100)", tooBig.Error);
|
||||
|
||||
var total = await client.PushAsync(Message(new PWMailerAttachment("a.pdf", new byte[100]), new PWMailerAttachment("b.pdf", new byte[51])));
|
||||
Assert.Contains("total 151 bytes (max 150", total.Error);
|
||||
|
||||
Assert.Equal(0, handler.Calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("leer.pdf", new byte[0], "is empty")]
|
||||
[InlineData("noext", new byte[] { 1 }, "no file extension")]
|
||||
public async Task PushAsync_BaseContractRules_StillAppliedThroughOverride(string name, byte[] bytes, string expected)
|
||||
{
|
||||
var (client, handler) = Make();
|
||||
|
||||
var result = await client.PushAsync(Message(new PWMailerAttachment(name, bytes)));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(expected, result.Error);
|
||||
Assert.Equal(0, handler.Calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Rechnung.pdf")]
|
||||
[InlineData("export.CSV")]
|
||||
[InlineData("archive.7z")]
|
||||
public async Task PushAsync_AllowedAttachment_IsSent(string name)
|
||||
{
|
||||
var (client, handler) = Make();
|
||||
|
||||
var result = await client.PushAsync(Message(new PWMailerAttachment(name, [1, 2])));
|
||||
|
||||
Assert.True(result.Success, result.Error);
|
||||
Assert.Equal(1, handler.Calls);
|
||||
Assert.Equal(1, result.Value!.AttachmentsProcessed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MailerAttachmentPolicy_NoAttachments_NoViolation()
|
||||
{
|
||||
Assert.Empty(MailerAttachmentPolicy.Validate(Array.Empty<PWMailerAttachment>(), new MailerAttachmentLimits()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using OCORE.security;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="InvoiceFileBackupService.BackupAndClearAsync"/>: the invoice-not-found
|
||||
/// short-circuit, that a blob backup-rename failure never blocks the DB-side backup/clear (best
|
||||
/// effort per Docs/Decisions/0027), and that the DB call failing is reported without throwing.
|
||||
/// No SQL Server: the DB-clear path uses an unconfigured connection so the write fails fast inside
|
||||
/// the OCORE helper, mirroring InvoiceServiceStoreFileTests.
|
||||
/// </summary>
|
||||
public class InvoiceFileBackupServiceTests
|
||||
{
|
||||
private sealed class RecordingLogger : ILogger<InvoiceFileBackupService>
|
||||
{
|
||||
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)));
|
||||
}
|
||||
|
||||
// A refused local port: the write fails inside the OCORE helper's own try/catch (returning an
|
||||
// Exception string) instead of hanging on a connect timeout.
|
||||
private const string UnreachableConnectionString =
|
||||
"Server=127.0.0.1,1;Database=fuchs;User Id=u;Password=p;Connect Timeout=1;TrustServerCertificate=True";
|
||||
|
||||
private static FdsInvoiceData Invoice(string id = "W7VAL", bool isFinal = true, bool isExternal = false, bool hasFile = true) => new()
|
||||
{
|
||||
InvoiceRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary<string, object?>
|
||||
{
|
||||
["Id"] = id,
|
||||
["InvoiceId"] = "R2026-0258",
|
||||
["DocumentName"] = $"Rechnung_{id}.pdf",
|
||||
["IsFinal"] = isFinal,
|
||||
["IsExternal"] = isExternal,
|
||||
["hasFile"] = hasFile,
|
||||
}),
|
||||
IsDraft = !isFinal,
|
||||
};
|
||||
|
||||
private static (InvoiceFileBackupService svc, RecordingLogger log, Mock<IInvoiceService> invoices, Mock<IBlobStorageService> blob)
|
||||
MakeService(FdsInvoiceData? invoice, string? connectionString = null)
|
||||
{
|
||||
var log = new RecordingLogger();
|
||||
var invoices = new Mock<IInvoiceService>();
|
||||
invoices.Setup(i => i.LoadInvoiceAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DatabaseSecurity>()))
|
||||
.ReturnsAsync(invoice ?? new FdsInvoiceData());
|
||||
var blob = new Mock<IBlobStorageService>();
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:fuchs_fds_ConnectionString"] = connectionString
|
||||
}).Build();
|
||||
var svc = new InvoiceFileBackupService(new Fuchs_intranet(config), invoices.Object, blob.Object, log);
|
||||
return (svc, log, invoices, blob);
|
||||
}
|
||||
|
||||
// LoadInvoiceAsync never yields a null registration for an unknown id — it yields an empty
|
||||
// dictionary (FirstRow.toObjectDictionary()), i.e. an invoice whose Id is "". Both shapes must
|
||||
// be recognised as "not found" and never fall through to the IsDraft check as "not_final".
|
||||
public static IEnumerable<object[]> NotFoundShapes() =>
|
||||
[
|
||||
[ new FdsInvoiceData() ],
|
||||
[ new FdsInvoiceData { InvoiceRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary<string, object?>()) } ],
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(NotFoundShapes))]
|
||||
public async Task BackupAndClearAsync_InvoiceNotFound_ReturnsFailureWithoutTouchingBlobOrDb(FdsInvoiceData loaded)
|
||||
{
|
||||
var (svc, log, invoices, blob) = MakeService(loaded);
|
||||
|
||||
var result = await svc.BackupAndClearAsync("MISSING", "user1", new DatabaseSecurity("user1", null));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("invoice_not_found", result.Reason);
|
||||
Assert.Null(result.BlobBackupUri);
|
||||
blob.Verify(b => b.CopyInvoicePdfToBackupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
var entry = Assert.Single(log.Entries);
|
||||
Assert.Equal(LogLevel.Warning, entry.Level);
|
||||
Assert.Contains("not found", entry.Message);
|
||||
}
|
||||
|
||||
// ADR 0025's regeneration precondition is load-bearing: clearing a draft's file would let the
|
||||
// next read persist a draft render that then blocks the real final PDF forever; an external
|
||||
// (MFR) document can't be regenerated at all. Both must be refused before anything is touched.
|
||||
[Theory]
|
||||
[InlineData(false, false, "not_final")]
|
||||
[InlineData(true, true, "external")]
|
||||
[InlineData(false, true, "not_final")]
|
||||
public async Task BackupAndClearAsync_DraftOrExternalInvoice_RefusesWithoutTouchingBlobOrDb(
|
||||
bool isFinal, bool isExternal, string expectedReason)
|
||||
{
|
||||
var (svc, log, invoices, blob) = MakeService(Invoice(isFinal: isFinal, isExternal: isExternal), UnreachableConnectionString);
|
||||
|
||||
var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(expectedReason, result.Reason);
|
||||
Assert.Null(result.BlobBackupUri);
|
||||
blob.Verify(b => b.CopyInvoicePdfToBackupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
var entry = Assert.Single(log.Entries);
|
||||
Assert.Equal(LogLevel.Warning, entry.Level);
|
||||
Assert.Contains("refused", entry.Message);
|
||||
Assert.DoesNotContain(log.Entries, e => e.Level == LogLevel.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackupAndClearAsync_NoStoredFile_ReturnsNoFileWithoutCopyingBlobOrTouchingDb()
|
||||
{
|
||||
// A repeated click on an already-cleared invoice must not leave another backup_* blob
|
||||
// behind: fds__getInvoice's hasFile settles it before the copy and before the proc.
|
||||
var (svc, log, invoices, blob) = MakeService(Invoice(hasFile: false), UnreachableConnectionString);
|
||||
|
||||
var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("no_file", result.Reason);
|
||||
Assert.Null(result.BlobBackupUri);
|
||||
blob.Verify(b => b.CopyInvoicePdfToBackupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
Assert.DoesNotContain(log.Entries, e => e.Level == LogLevel.Error); // the proc was never called
|
||||
Assert.Contains(log.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("no file stored"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackupAndClearAsync_BlobCopySucceeds_DbFails_ReportsDbErrorWithBackupUri()
|
||||
{
|
||||
// The backup copy is taken BEFORE the DB clear (so no regeneration can overwrite the
|
||||
// original first) and the original blob is kept; a DB failure (here: no real SQL Server)
|
||||
// therefore leaves the archive intact plus a spare copy, which the result still reports.
|
||||
var backupUri = new Uri("https://test.blob.core.windows.net/invoices/W7VAL/backup_x.pdf");
|
||||
var (svc, log, invoices, blob) = MakeService(Invoice(), UnreachableConnectionString);
|
||||
blob.Setup(b => b.CopyInvoicePdfToBackupAsync("W7VAL", "Rechnung_W7VAL.pdf", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(backupUri);
|
||||
|
||||
var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("db_error", result.Reason);
|
||||
Assert.Equal(backupUri, result.BlobBackupUri);
|
||||
blob.Verify(b => b.CopyInvoicePdfToBackupAsync("W7VAL", "Rechnung_W7VAL.pdf", It.IsAny<CancellationToken>()), Times.Once);
|
||||
Assert.Contains(log.Entries, e => e.Level == LogLevel.Error && e.Message.Contains("fds__admin_backupAndClearInvoiceFile failed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackupAndClearAsync_BlobCopyThrows_StillAttemptsDbClearAndReturnsDbOutcome()
|
||||
{
|
||||
var (svc, log, invoices, blob) = MakeService(Invoice(), UnreachableConnectionString);
|
||||
blob.Setup(b => b.CopyInvoicePdfToBackupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("blob boom"));
|
||||
|
||||
var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null));
|
||||
|
||||
// The blob exception is swallowed and logged; the DB step still runs (and fails here for
|
||||
// lack of a SQL Server) rather than the exception escaping BackupAndClearAsync.
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("db_error", result.Reason);
|
||||
Assert.Null(result.BlobBackupUri);
|
||||
Assert.Contains(log.Entries, e => e.Level == LogLevel.Error && e.Message.Contains("blob backup-copy threw"));
|
||||
Assert.Contains(log.Entries, e => e.Level == LogLevel.Error && e.Message.Contains("fds__admin_backupAndClearInvoiceFile failed"));
|
||||
}
|
||||
}
|
||||
@@ -1,67 +1,73 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
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 Moq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using OCORE.web.pwmailer;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the outbound communication service. Cover both intentionally
|
||||
/// succeeding (API 200) and intentionally failing (invalid email, disabled,
|
||||
/// API 500) paths, plus the attachment payload contract and metric emission.
|
||||
/// Tests for the outbound communication service as an adapter over the OCORE_web
|
||||
/// <see cref="IPWMailerClientService"/> (ADR 0029). Cover both intentionally succeeding and
|
||||
/// intentionally failing paths (invalid email, disabled, API error, duplicate, refused by
|
||||
/// pre-validation, attachment count not confirmed), the <see cref="PWMailerMessage"/> handed to
|
||||
/// the client, the audit log written on every path, and metric emission. The HTTP contract itself
|
||||
/// (JSON vs multipart, unnamed envelope field, attachmentCount) is the client's responsibility and
|
||||
/// is tested in OCORE_webTests/comm/PWMailerClientServiceTests.cs.
|
||||
///
|
||||
/// Fuchs_intranet is passed as null: it is only touched by the audit-log write,
|
||||
/// which is wrapped in try/catch and therefore harmless in a unit test.
|
||||
/// </summary>
|
||||
public class ProcessWebComServiceTests
|
||||
{
|
||||
private const string FuchsMeterName = "Fuchs.Intranet";
|
||||
|
||||
// ── Test doubles ────────────────────────────────────────────────────────
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _status;
|
||||
private readonly string _body;
|
||||
public string? LastRequestBody { get; private set; }
|
||||
public int CallCount { get; private set; }
|
||||
private static PWMailerResult<PWMailerPushReceipt> Accepted(int received = 0, int processed = 0, string comStackId = "cs-1") =>
|
||||
new() { StatusCode = 200, Value = new PWMailerPushReceipt(comStackId, received, processed), Attempts = 1, Operation = "push_com" };
|
||||
|
||||
public StubHandler(HttpStatusCode status, string body = "OK")
|
||||
private static PWMailerResult<PWMailerPushReceipt> Failed(int status, string error) =>
|
||||
new() { StatusCode = status, Error = error, Attempts = 1, Operation = "push_com" };
|
||||
|
||||
/// <summary>The client refused the call before sending (validation, options, token): status 0, no attempt.</summary>
|
||||
private static PWMailerResult<PWMailerPushReceipt> Refused(string error) =>
|
||||
new() { StatusCode = 0, Error = error, Attempts = 0, Operation = "push_com", Exception = new ArgumentException(error) };
|
||||
|
||||
private sealed class MailerStub
|
||||
{
|
||||
public readonly Mock<IPWMailerClientService> Mock = new(MockBehavior.Loose);
|
||||
public readonly List<PWMailerMessage> Pushed = new();
|
||||
public int CallCount => Pushed.Count;
|
||||
|
||||
public MailerStub(PWMailerResult<PWMailerPushReceipt>? push = null)
|
||||
{
|
||||
_status = status;
|
||||
_body = body;
|
||||
var result = push ?? Accepted();
|
||||
Mock.Setup(m => m.PushAsync(It.IsAny<PWMailerMessage>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<PWMailerMessage, CancellationToken>((m, _) => Pushed.Add(m))
|
||||
.ReturnsAsync(result);
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
/// <summary>Answers like a Mailer that processed every file it received.</summary>
|
||||
public static MailerStub Echoing()
|
||||
{
|
||||
CallCount++;
|
||||
LastRequestBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
return new HttpResponseMessage(_status) { Content = new StringContent(_body) };
|
||||
var stub = new MailerStub();
|
||||
stub.Mock.Setup(m => m.PushAsync(It.IsAny<PWMailerMessage>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<PWMailerMessage, CancellationToken>((m, _) => stub.Pushed.Add(m))
|
||||
.ReturnsAsync((PWMailerMessage m, CancellationToken _) => Accepted(m.Attachments.Count, m.Attachments.Count));
|
||||
return stub;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> throw new HttpRequestException("simulated transport failure");
|
||||
}
|
||||
|
||||
private sealed class StubHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpMessageHandler _handler;
|
||||
public StubHttpClientFactory(HttpMessageHandler handler) => _handler = handler;
|
||||
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures what would be written to <c>fds__logEmail</c> without hitting a database, by
|
||||
/// overriding the audit-log write. Lets tests assert that every send path is logged and that
|
||||
@@ -71,146 +77,128 @@ public class ProcessWebComServiceTests
|
||||
{
|
||||
public readonly List<(bool success, List<string> log)> Entries = new();
|
||||
public readonly List<string> Configs = new();
|
||||
public readonly List<string> Guids = new();
|
||||
|
||||
public CapturingComService(
|
||||
IOptions<ProcessWebComSettings> settings,
|
||||
IOptions<FuchsEmailSettings> emailSettings,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
: base(NullLogger<ProcessWebComService>.Instance, intranet: null!, settings, emailSettings, httpClientFactory)
|
||||
{
|
||||
}
|
||||
public CapturingComService(IOptions<ProcessWebComSettings> settings, IOptions<FuchsEmailSettings> emailSettings,
|
||||
IPWMailerClientService mailer)
|
||||
: base(NullLogger<ProcessWebComService>.Instance, intranet: null!, settings, emailSettings, mailer) { }
|
||||
|
||||
protected override Task WriteAuditLogAsync(string reference, string guid, string config,
|
||||
DateTime sent, bool success, IEnumerable<string> errors)
|
||||
{
|
||||
Entries.Add((success, errors.ToList()));
|
||||
Configs.Add(config);
|
||||
Guids.Add(guid);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static CapturingComService CreateCapturing(HttpMessageHandler handler, bool enabled = true, string? overrideRecipient = null)
|
||||
{
|
||||
var settings = Options.Create(new ProcessWebComSettings
|
||||
private static IOptions<ProcessWebComSettings> Settings(bool enabled = true, string accountId = "acct", string token = "tok", string serverId = "") =>
|
||||
Options.Create(new ProcessWebComSettings
|
||||
{
|
||||
Enabled = enabled, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok"
|
||||
Enabled = enabled, BaseUrl = "https://mailer.test", AccountId = accountId, Token = token, ServerId = serverId
|
||||
});
|
||||
var emailSettings = Options.Create(new FuchsEmailSettings { OverrideRecipient = overrideRecipient });
|
||||
return new CapturingComService(settings, emailSettings, new StubHttpClientFactory(handler));
|
||||
}
|
||||
|
||||
private static ProcessWebComService CreateService(StubHandler handler, bool enabled = true, string? overrideRecipient = null)
|
||||
{
|
||||
var settings = Options.Create(new ProcessWebComSettings
|
||||
{
|
||||
Enabled = enabled,
|
||||
BaseUrl = "https://mailer.test",
|
||||
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));
|
||||
}
|
||||
private static CapturingComService CreateCapturing(MailerStub mailer, bool enabled = true, string? overrideRecipient = null) =>
|
||||
new(Settings(enabled), Options.Create(new FuchsEmailSettings { OverrideRecipient = overrideRecipient }), mailer.Mock.Object);
|
||||
|
||||
private static ProcessWebComService CreateService(MailerStub mailer, bool enabled = true, string? overrideRecipient = null) =>
|
||||
new(NullLogger<ProcessWebComService>.Instance, intranet: null!, Settings(enabled),
|
||||
Options.Create(new FuchsEmailSettings { OverrideRecipient = overrideRecipient }), mailer.Mock.Object);
|
||||
|
||||
// ── Success path ─────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_ValidAndApiOk_ReturnsTrueAndPostsOnce()
|
||||
public async Task SendEmailAsync_ValidAndApiOk_ReturnsTrueAndPushesOnce()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_1", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
Assert.Equal(1, mailer.CallCount);
|
||||
}
|
||||
|
||||
// ── Failure paths ──────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_ApiError_ReturnsFalse()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.InternalServerError, "boom");
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub(Failed(500, "boom"));
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_2", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
Assert.Equal(1, mailer.CallCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-an-email")]
|
||||
[InlineData("")]
|
||||
[InlineData("missing@")]
|
||||
public async Task SendEmailAsync_InvalidEmail_ReturnsFalseWithoutCallingApi(string badEmail)
|
||||
public async Task SendEmailAsync_InvalidEmail_ReturnsFalseWithoutCallingMailer(string badEmail)
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_3", "Subject", "<p>hi</p>", badEmail, "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, handler.CallCount); // never reached the API
|
||||
Assert.Equal(0, mailer.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_Disabled_ReturnsFalseWithoutCallingApi()
|
||||
public async Task SendEmailAsync_Disabled_ReturnsFalseWithoutCallingMailer()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, enabled: false);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer, enabled: false);
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_4", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
Assert.Equal(0, mailer.CallCount);
|
||||
}
|
||||
|
||||
// ── Audit logging: every attempt is logged; failures carry the service response ─
|
||||
// ── Audit logging: every attempt is logged; failures carry the client's error ─
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_Success_LogsSuccessfulAttempt()
|
||||
public async Task SendEmailAsync_Success_LogsSuccessfulAttemptWithComStackId()
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK));
|
||||
var svc = CreateCapturing(new MailerStub(Accepted(comStackId: "cs-42")));
|
||||
|
||||
await svc.SendEmailAsync("inv_log_ok", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.True(entry.success);
|
||||
Assert.Equal("cs-42", Assert.Single(svc.Guids)); // what getComStatus/getCom accept
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_ApiError_LogsFailureIncludingServiceResponse()
|
||||
public async Task SendEmailAsync_ApiError_LogsFailureIncludingStatusAndError()
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(HttpStatusCode.InternalServerError, "mailer rejected: quota exceeded"));
|
||||
var svc = CreateCapturing(new MailerStub(Failed(500, "mailer rejected: quota exceeded")));
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_log_api", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.False(entry.success);
|
||||
Assert.Contains(entry.log, l => l.Contains("mailer rejected: quota exceeded")); // raw service response
|
||||
Assert.Contains(entry.log, l => l.Contains("500")); // HTTP status
|
||||
Assert.Contains(entry.log, l => l.Contains("mailer rejected: quota exceeded") && l.Contains("500"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_TransportException_LogsFailureIncludingExceptionDetail()
|
||||
public async Task SendEmailAsync_ClientThrows_LogsFailureIncludingExceptionDetail()
|
||||
{
|
||||
var svc = CreateCapturing(new ThrowingHandler());
|
||||
var mailer = new MailerStub();
|
||||
mailer.Mock.Setup(m => m.PushAsync(It.IsAny<PWMailerMessage>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("simulated client failure"));
|
||||
var svc = CreateCapturing(mailer);
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_log_ex", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.False(entry.success);
|
||||
Assert.Contains(entry.log, l => l.Contains("Exception while sending"));
|
||||
Assert.Contains(entry.log, l => l.Contains("simulated transport failure"));
|
||||
Assert.Contains(entry.log, l => l.Contains("Exception while sending") && l.Contains("simulated client failure"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -218,7 +206,7 @@ public class ProcessWebComServiceTests
|
||||
[InlineData("")]
|
||||
public async Task SendEmailAsync_InvalidEmail_StillLogsFailedAttempt(string badEmail)
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK));
|
||||
var svc = CreateCapturing(new MailerStub());
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_log_bad", "S", "<p>x</p>", badEmail, "Kunde");
|
||||
|
||||
@@ -231,7 +219,7 @@ public class ProcessWebComServiceTests
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_Disabled_LogsFailedAttempt()
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK), enabled: false);
|
||||
var svc = CreateCapturing(new MailerStub(), enabled: false);
|
||||
|
||||
await svc.SendEmailAsync("inv_log_dis", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
@@ -317,18 +305,19 @@ public class ProcessWebComServiceTests
|
||||
|
||||
public static IEnumerable<object[]> AllAuditPaths() =>
|
||||
[
|
||||
[ "success", HttpStatusCode.OK, true, "kunde@example.de" ],
|
||||
[ "api-error", HttpStatusCode.InternalServerError, true, "kunde@example.de" ],
|
||||
[ "disabled", HttpStatusCode.OK, false, "kunde@example.de" ],
|
||||
[ "invalid-email", HttpStatusCode.OK, true, "not-an-email" ],
|
||||
[ "success", 200, true, "kunde@example.de" ],
|
||||
[ "api-error", 500, true, "kunde@example.de" ],
|
||||
[ "disabled", 200, false, "kunde@example.de" ],
|
||||
[ "invalid-email", 200, true, "not-an-email" ],
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AllAuditPaths))]
|
||||
public async Task SendEmailAsync_EveryAuditPath_LogsParsableConfigWithRecipientSubjectAndAttachments(
|
||||
string path, HttpStatusCode status, bool enabled, string email)
|
||||
string path, int status, bool enabled, string email)
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(status), enabled: enabled);
|
||||
var mailer = status == 200 ? MailerStub.Echoing() : new MailerStub(Failed(status, "err"));
|
||||
var svc = CreateCapturing(mailer, enabled: enabled);
|
||||
var att = new Dictionary<string, byte[]> { ["Mahnung.pdf"] = [9, 9] };
|
||||
|
||||
await svc.SendEmailAsync("inv_cfg_" + path, "Subj " + path, "<p>x</p>", email, "Kunde", att);
|
||||
@@ -343,7 +332,7 @@ public class ProcessWebComServiceTests
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_OverrideRecipientSet_ConfigRecordsActualRecipientAndPrefixedSubject()
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK), overrideRecipient: "dev-inbox@example.test");
|
||||
var svc = CreateCapturing(new MailerStub(), overrideRecipient: "dev-inbox@example.test");
|
||||
|
||||
await svc.SendEmailAsync("inv_cfg_ovr", "Subject", "<p>x</p>", "kunde@example.de", "Kunde", null, cc: "cc@example.de");
|
||||
|
||||
@@ -357,27 +346,24 @@ public class ProcessWebComServiceTests
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_OverrideRecipientSet_RedirectsToOverrideAddress()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test");
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer, 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["communication"]!["to"]!.ToString());
|
||||
Assert.Equal("dev-inbox@example.test", Assert.Single(mailer.Pushed).To);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_OverrideRecipientSet_SubjectRetainsOriginalRecipientForTraceability()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test");
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer, 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["communication"]!["subject"]!.ToString();
|
||||
string subject = Assert.Single(mailer.Pushed).Subject;
|
||||
Assert.Contains("realcustomer@example.de", subject);
|
||||
Assert.Contains("Rechnung 123", subject);
|
||||
}
|
||||
@@ -385,84 +371,69 @@ public class ProcessWebComServiceTests
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_OverrideRecipientSet_InvalidOriginalAddressIsStillRedirected()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test");
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer, 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["communication"]!["to"]!.ToString());
|
||||
Assert.Equal("dev-inbox@example.test", Assert.Single(mailer.Pushed).To);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_NoOverrideConfigured_SendsToOriginalRecipient()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
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["communication"]!["to"]!.ToString());
|
||||
Assert.Equal("realcustomer@example.de", Assert.Single(mailer.Pushed).To);
|
||||
}
|
||||
|
||||
// ── Cc recipient ("Zusatzempfänger") ─────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_CcProvided_IsIncludedInPayload()
|
||||
public async Task SendEmailAsync_CcProvided_IsIncludedInMessage()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
await svc.SendEmailAsync("inv_cc1", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde",
|
||||
cc: "zusatz@example.de");
|
||||
await svc.SendEmailAsync("inv_cc1", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde", cc: "zusatz@example.de");
|
||||
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
Assert.Equal("zusatz@example.de", json["communication"]!["cc"]!.ToString());
|
||||
Assert.Equal("zusatz@example.de", Assert.Single(mailer.Pushed).Cc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_CcOmitted_IsNullInPayload()
|
||||
public async Task SendEmailAsync_CcOmitted_IsNullInMessage()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
await svc.SendEmailAsync("inv_cc2", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
Assert.Equal(JTokenType.Null, json["communication"]!["cc"]!.Type);
|
||||
Assert.Null(Assert.Single(mailer.Pushed).Cc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_OverrideRecipientSet_DropsCcEntirely()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test");
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer, overrideRecipient: "dev-inbox@example.test");
|
||||
|
||||
await svc.SendEmailAsync("inv_cc3", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde",
|
||||
cc: "zusatz@example.de");
|
||||
await svc.SendEmailAsync("inv_cc3", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde", cc: "zusatz@example.de");
|
||||
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString());
|
||||
Assert.Equal(JTokenType.Null, json["communication"]!["cc"]!.Type);
|
||||
var m = Assert.Single(mailer.Pushed);
|
||||
Assert.Equal("dev-inbox@example.test", m.To);
|
||||
Assert.Null(m.Cc);
|
||||
}
|
||||
|
||||
// ── 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 "communication.to" field - there is no distinct
|
||||
// to/cc/bcc concept anywhere in the codebase (see ProcessWebComService.
|
||||
// SendEmailAsync / communication.to). "to/cc/bcc cleared" is therefore fully
|
||||
// satisfied by asserting that field no longer carries the original recipient
|
||||
// once an override is configured.
|
||||
// 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. 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.
|
||||
public static IEnumerable<object[]> AppSettingsEnvironments()
|
||||
{
|
||||
yield return new object[] { "" }; // base appsettings.json only ("Production"-like default)
|
||||
@@ -489,8 +460,8 @@ public class ProcessWebComServiceTests
|
||||
public async Task SendEmailAsync_OverrideRecipientFromRealAppsettings_ClearsRecipientWheneverConfigured(string environmentName)
|
||||
{
|
||||
string overrideRecipient = ResolveOverrideRecipientForEnvironment(environmentName);
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, overrideRecipient: overrideRecipient);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer, overrideRecipient: overrideRecipient);
|
||||
|
||||
const string originalRecipient = "realcustomer@tenant-owner.example";
|
||||
bool result = await svc.SendEmailAsync(
|
||||
@@ -498,134 +469,226 @@ public class ProcessWebComServiceTests
|
||||
"Subject", "<p>hi</p>", originalRecipient, "Kunde");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
string sentRecipient = json["communication"]!["to"]!.ToString();
|
||||
|
||||
string sentRecipient = Assert.Single(mailer.Pushed).To;
|
||||
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 ────────────────────────────────────────────
|
||||
// ── The message handed to the PWMailer client ─────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_WithAttachment_EmbedsBase64InPayload()
|
||||
public async Task SendEmailAsync_MapsEnvelopeFieldsAndAttachmentsOntoPWMailerMessage()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
byte[] pdf = { 1, 2, 3, 4 };
|
||||
var attachments = new Dictionary<string, byte[]> { ["Rechnung.pdf"] = pdf };
|
||||
var mailer = MailerStub.Echoing();
|
||||
var svc = CreateService(mailer);
|
||||
byte[] pdf = { 0x25, 0x50, 0x44, 0x46 };
|
||||
byte[] xml = { 0x3C, 0x3F };
|
||||
var attachments = new Dictionary<string, byte[]> { ["Rechnung R2026-0119.pdf"] = pdf, ["zugferd.xml"] = xml };
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_5", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde", attachments);
|
||||
bool result = await svc.SendEmailAsync("inv_map", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde", attachments, cc: "cc@example.de");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.NotNull(handler.LastRequestBody);
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
var att = (JArray)json["communication"]!["attachments"]!;
|
||||
Assert.Single(att);
|
||||
Assert.Equal("Rechnung.pdf", att[0]!["filename"]!.ToString());
|
||||
Assert.Equal("application/pdf", att[0]!["mimeType"]!.ToString());
|
||||
Assert.Equal(Convert.ToBase64String(pdf), att[0]!["contentBase64"]!.ToString());
|
||||
var m = Assert.Single(mailer.Pushed);
|
||||
Assert.Equal("e", m.ComType);
|
||||
Assert.Equal("inv_map", m.Reference);
|
||||
Assert.Equal("kunde@example.de", m.To);
|
||||
Assert.Equal("cc@example.de", m.Cc);
|
||||
Assert.Equal("Subject", m.Subject);
|
||||
Assert.StartsWith("<p>hi</p>", m.Body); // signature is appended after the body
|
||||
Assert.Null(m.AttachmentCount); // null ⇒ client sends the real count
|
||||
Assert.Equal(2, m.Attachments.Count);
|
||||
Assert.Equal(pdf, m.Attachments.Single(a => a.FileName == "Rechnung R2026-0119.pdf").Bytes);
|
||||
Assert.Equal(xml, m.Attachments.Single(a => a.FileName == "zugferd.xml").Bytes);
|
||||
}
|
||||
|
||||
// ── Response checks ────────────────────────────────────────────────────────
|
||||
[Theory]
|
||||
[InlineData(1, 0)]
|
||||
[InlineData(0, 0)]
|
||||
public async Task SendEmailAsync_AttachmentsNotConfirmedProcessed_ReturnsFalseAndAuditsCounts(int received, int processed)
|
||||
{
|
||||
// A 200 is not enough when files were sent: the Mailer must report every one as processed.
|
||||
var svc = CreateCapturing(new MailerStub(Accepted(received, processed, "cs-9")));
|
||||
var att = new Dictionary<string, byte[]> { ["Rechnung.pdf"] = [1, 2, 3] };
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_mismatch", "S", "<p>x</p>", "kunde@example.de", "Kunde", att);
|
||||
|
||||
Assert.False(result);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.False(entry.success);
|
||||
Assert.Contains(entry.log, l => l.Contains("not all 1 attachment(s)")
|
||||
&& l.Contains($"attachmentsReceived={received}") && l.Contains($"attachmentsProcessed={processed}"));
|
||||
Assert.Equal("cs-9", Assert.Single(svc.Guids)); // still traceable at the Mailer
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_AttachmentsMismatch_IncrementsFailedCounterWithReason()
|
||||
{
|
||||
string? reason = null;
|
||||
using var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (inst, l) =>
|
||||
{
|
||||
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.emails.failed")
|
||||
l.EnableMeasurementEvents(inst);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((_, _, tags, _) =>
|
||||
{
|
||||
foreach (var t in tags) if (t.Key == "reason") reason = t.Value?.ToString();
|
||||
});
|
||||
listener.Start();
|
||||
|
||||
var svc = CreateService(new MailerStub(Accepted(1, 0)));
|
||||
await svc.SendEmailAsync("inv_mm_metric", "S", "<p>x</p>", "kunde@example.de", "Kunde",
|
||||
new Dictionary<string, byte[]> { ["a.pdf"] = [1] });
|
||||
|
||||
Assert.Equal("attachments-mismatch", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_Conflict409_IsSuccessBecauseThePendingDuplicateWillBeSent()
|
||||
{
|
||||
// A resend of an identical message while the first is still pending: the Mailer keeps the
|
||||
// first entry and delivers it — the caller (e.g. req/resend, or a double click) must see a
|
||||
// successful send, with the deduplication recorded in the audit log.
|
||||
var svc = CreateCapturing(new MailerStub(Failed(409, "duplicate of pending entry")));
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_dup", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.True(result);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.True(entry.success);
|
||||
Assert.Contains(entry.log, l => l.Contains("still-pending") && l.Contains("409") && l.Contains("duplicate of pending entry"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_RefusedByClientPreValidation_ReturnsFalseAndAuditsEachRule()
|
||||
{
|
||||
// The client refuses before sending (status 0, no attempt) with "; "-joined rule messages —
|
||||
// e.g. Fuchs' attachment limits (FuchsMailerClient) or the contract's required fields.
|
||||
var svc = CreateCapturing(new MailerStub(Refused("Attachment 'big.pdf' is 11,000,000 bytes (max 10,485,760); attachment 'x' has no file extension")));
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_refused", "S", "<p>x</p>", "kunde@example.de", "Kunde",
|
||||
new Dictionary<string, byte[]> { ["big.pdf"] = [1] });
|
||||
|
||||
Assert.False(result);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.False(entry.success);
|
||||
Assert.Equal(2, entry.log.Count);
|
||||
Assert.Contains(entry.log, l => l.StartsWith("Attachment 'big.pdf'"));
|
||||
Assert.Contains(entry.log, l => l.Contains("no file extension"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_RefusedBecauseClientNotUsable_ReturnsFalseWithClientConfigReason()
|
||||
{
|
||||
// Same shape as a validation refusal (status 0, no attempt) but no ArgumentException: the
|
||||
// client could not resolve its token / options. Must not be reported as caller validation.
|
||||
string? reason = null;
|
||||
using var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (inst, l) =>
|
||||
{
|
||||
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.emails.failed")
|
||||
l.EnableMeasurementEvents(inst);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((_, _, tags, _) =>
|
||||
{
|
||||
foreach (var t in tags) if (t.Key == "reason") reason = t.Value?.ToString();
|
||||
});
|
||||
listener.Start();
|
||||
|
||||
var svc = CreateCapturing(new MailerStub(new PWMailerResult<PWMailerPushReceipt>
|
||||
{
|
||||
StatusCode = 0, Attempts = 0, Operation = "push_com",
|
||||
Error = "PWMailer token could not be loaded from Key Vault secret 'Fuchs--Mailer--Token': timeout"
|
||||
}));
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_cfg", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal("client-config", reason);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.False(entry.success);
|
||||
Assert.Contains(entry.log, l => l.StartsWith("Mailer client not usable") && l.Contains("Key Vault"));
|
||||
}
|
||||
|
||||
// ── SMS ────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendSmsAsync_ValidAndApiOk_ReturnsTrue()
|
||||
public async Task SendSmsAsync_ValidAndApiOk_ReturnsTrueAndPushesComTypeS()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
bool result = await svc.SendSmsAsync("01700000000", "Code 1234");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
var m = Assert.Single(mailer.Pushed);
|
||||
Assert.Equal("s", m.ComType);
|
||||
Assert.Equal("01700000000", m.To);
|
||||
Assert.Equal("Code 1234", m.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendSmsAsync_EmptyMobile_ReturnsFalseWithoutCallingApi()
|
||||
public async Task SendSmsAsync_EmptyMobile_ReturnsFalseWithoutCallingMailer()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
bool result = await svc.SendSmsAsync("", "Code 1234");
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
Assert.Equal(0, mailer.CallCount);
|
||||
}
|
||||
|
||||
// ── Credential verification (GET /mailer/fc?fn=verify) ─────────────────────
|
||||
private sealed class CapturingRequestHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _status;
|
||||
private readonly string _body;
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
// ── Credential verification (client VerifyAsync) ────────────────────────────
|
||||
private static PWMailerResult<PWMailerVerifyResult> VerifyResult(bool ok, string account = "acc-123", string? serverId = null) =>
|
||||
new() { StatusCode = 200, Value = new PWMailerVerifyResult(ok, account, serverId), Attempts = 1, Operation = "verify" };
|
||||
|
||||
public CapturingRequestHandler(HttpStatusCode status, string body) { _status = status; _body = body; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
return Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) });
|
||||
}
|
||||
}
|
||||
private static ProcessWebComService VerifyService(Mock<IPWMailerClientService> mailer, string accountId = "acct", string token = "tok", string serverId = "") =>
|
||||
new(NullLogger<ProcessWebComService>.Instance, intranet: null!, Settings(true, accountId, token, serverId),
|
||||
Options.Create(new FuchsEmailSettings()), mailer.Object);
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyCredentialsAsync_MailerConfirms_ReturnsOkWithAccount()
|
||||
{
|
||||
var handler = new CapturingRequestHandler(HttpStatusCode.OK, "{\"ok\":true,\"account\":\"acc-123\"}");
|
||||
var svc = new ProcessWebComService(
|
||||
NullLogger<ProcessWebComService>.Instance, intranet: null!,
|
||||
Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok" }),
|
||||
Options.Create(new FuchsEmailSettings()),
|
||||
new StubHttpClientFactory(handler));
|
||||
var mailer = new Mock<IPWMailerClientService>();
|
||||
mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>())).ReturnsAsync(VerifyResult(true));
|
||||
|
||||
var result = await svc.VerifyCredentialsAsync();
|
||||
var result = await VerifyService(mailer).VerifyCredentialsAsync();
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.Equal("acc-123", result.Account);
|
||||
Assert.NotNull(handler.LastRequest);
|
||||
Assert.Equal(HttpMethod.Get, handler.LastRequest!.Method);
|
||||
Assert.Equal("https://mailer.test/mailer/fc?fn=verify", handler.LastRequest.RequestUri!.ToString());
|
||||
Assert.Equal("Basic", handler.LastRequest.Headers.Authorization!.Scheme);
|
||||
mailer.Verify(m => m.VerifyAsync(null, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyCredentialsAsync_ServerIdConfigured_IsSentAndEchoedOnSuccess()
|
||||
{
|
||||
var handler = new CapturingRequestHandler(HttpStatusCode.OK, "{\"ok\":true,\"account\":\"acc-123\",\"serverid\":\"srv-1\"}");
|
||||
var svc = new ProcessWebComService(
|
||||
NullLogger<ProcessWebComService>.Instance, intranet: null!,
|
||||
Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok", ServerId = "srv-1" }),
|
||||
Options.Create(new FuchsEmailSettings()),
|
||||
new StubHttpClientFactory(handler));
|
||||
var mailer = new Mock<IPWMailerClientService>();
|
||||
mailer.Setup(m => m.VerifyAsync("srv-1", It.IsAny<CancellationToken>())).ReturnsAsync(VerifyResult(true, serverId: "srv-1"));
|
||||
|
||||
var result = await svc.VerifyCredentialsAsync();
|
||||
var result = await VerifyService(mailer, serverId: "srv-1").VerifyCredentialsAsync();
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.Equal("srv-1", result.ServerId);
|
||||
Assert.Equal("https://mailer.test/mailer/fc?fn=verify&serverid=srv-1", handler.LastRequest!.RequestUri!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyCredentialsAsync_ServerIdNotOwnedByAccount_ReturnsNotOkWithServerIdDetail()
|
||||
{
|
||||
var handler = new CapturingRequestHandler(HttpStatusCode.OK, "{\"ok\":false,\"account\":\"acc-123\",\"serverid\":\"srv-wrong\"}");
|
||||
var svc = new ProcessWebComService(
|
||||
NullLogger<ProcessWebComService>.Instance, intranet: null!,
|
||||
Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok", ServerId = "srv-wrong" }),
|
||||
Options.Create(new FuchsEmailSettings()),
|
||||
new StubHttpClientFactory(handler));
|
||||
var mailer = new Mock<IPWMailerClientService>();
|
||||
mailer.Setup(m => m.VerifyAsync("srv-wrong", It.IsAny<CancellationToken>())).ReturnsAsync(VerifyResult(false, serverId: "srv-wrong"));
|
||||
|
||||
var result = await svc.VerifyCredentialsAsync();
|
||||
var result = await VerifyService(mailer, serverId: "srv-wrong").VerifyCredentialsAsync();
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("srv-wrong", result.Detail);
|
||||
@@ -634,44 +697,37 @@ public class ProcessWebComServiceTests
|
||||
[Fact]
|
||||
public async Task VerifyCredentialsAsync_MailerReturnsOkFalse_ReturnsNotOk()
|
||||
{
|
||||
var handler = new CapturingRequestHandler(HttpStatusCode.OK, "{\"ok\":false}");
|
||||
var svc = new ProcessWebComService(
|
||||
NullLogger<ProcessWebComService>.Instance, intranet: null!,
|
||||
Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok" }),
|
||||
Options.Create(new FuchsEmailSettings()),
|
||||
new StubHttpClientFactory(handler));
|
||||
var mailer = new Mock<IPWMailerClientService>();
|
||||
mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>())).ReturnsAsync(VerifyResult(false));
|
||||
|
||||
var result = await svc.VerifyCredentialsAsync();
|
||||
var result = await VerifyService(mailer).VerifyCredentialsAsync();
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("ungültige Zugangsdaten", result.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyCredentialsAsync_Http401_ReturnsNotOkWithDetail()
|
||||
{
|
||||
var handler = new CapturingRequestHandler(HttpStatusCode.Unauthorized, "unauthorized");
|
||||
var svc = new ProcessWebComService(
|
||||
NullLogger<ProcessWebComService>.Instance, intranet: null!,
|
||||
Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "wrong" }),
|
||||
Options.Create(new FuchsEmailSettings()),
|
||||
new StubHttpClientFactory(handler));
|
||||
var mailer = new Mock<IPWMailerClientService>();
|
||||
mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PWMailerResult<PWMailerVerifyResult> { StatusCode = 401, Error = "not authenticated", Attempts = 1 });
|
||||
|
||||
var result = await svc.VerifyCredentialsAsync();
|
||||
var result = await VerifyService(mailer, token: "wrong").VerifyCredentialsAsync();
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("401", result.Detail);
|
||||
Assert.Contains("not authenticated", result.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyCredentialsAsync_TransportException_ReturnsNotOkWithExceptionDetail()
|
||||
public async Task VerifyCredentialsAsync_TransportError_ReturnsNotOkWithClientError()
|
||||
{
|
||||
var svc = new ProcessWebComService(
|
||||
NullLogger<ProcessWebComService>.Instance, intranet: null!,
|
||||
Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok" }),
|
||||
Options.Create(new FuchsEmailSettings()),
|
||||
new StubHttpClientFactory(new ThrowingHandler()));
|
||||
var mailer = new Mock<IPWMailerClientService>();
|
||||
mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PWMailerResult<PWMailerVerifyResult> { StatusCode = 0, Error = "simulated transport failure", Attempts = 3, TimedOut = false });
|
||||
|
||||
var result = await svc.VerifyCredentialsAsync();
|
||||
var result = await VerifyService(mailer).VerifyCredentialsAsync();
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("simulated transport failure", result.Detail);
|
||||
@@ -680,19 +736,14 @@ public class ProcessWebComServiceTests
|
||||
[Theory]
|
||||
[InlineData("", "tok")]
|
||||
[InlineData("acct", "")]
|
||||
public async Task VerifyCredentialsAsync_CredentialsNotConfigured_ReturnsNotOkWithoutCallingApi(string accountId, string token)
|
||||
public async Task VerifyCredentialsAsync_CredentialsNotConfigured_ReturnsNotOkWithoutCallingClient(string accountId, string token)
|
||||
{
|
||||
var handler = new CapturingRequestHandler(HttpStatusCode.OK, "{\"ok\":true}");
|
||||
var svc = new ProcessWebComService(
|
||||
NullLogger<ProcessWebComService>.Instance, intranet: null!,
|
||||
Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = accountId, Token = token }),
|
||||
Options.Create(new FuchsEmailSettings()),
|
||||
new StubHttpClientFactory(handler));
|
||||
var mailer = new Mock<IPWMailerClientService>();
|
||||
|
||||
var result = await svc.VerifyCredentialsAsync();
|
||||
var result = await VerifyService(mailer, accountId, token).VerifyCredentialsAsync();
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Null(handler.LastRequest);
|
||||
mailer.Verify(m => m.VerifyAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
// ── Metric emission (performance indicator) ────────────────────────────────
|
||||
@@ -711,11 +762,9 @@ public class ProcessWebComServiceTests
|
||||
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
|
||||
listener.Start();
|
||||
|
||||
var svc = CreateService(new StubHandler(HttpStatusCode.OK));
|
||||
var svc = CreateService(new MailerStub());
|
||||
await svc.SendEmailAsync("inv_metric", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.True(delta >= 1, "fuchs.emails.sent counter should have been incremented on a successful send.");
|
||||
}
|
||||
|
||||
private const string FuchsMeterName = "Fuchs.Intranet";
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -24,6 +25,7 @@ namespace Fuchs.Tests;
|
||||
public class SystemStatusServiceTests
|
||||
{
|
||||
private const string FuchsMeterName = "Fuchs.Intranet";
|
||||
private static readonly byte[] FakePdfBytes = { 0x25, 0x50, 0x44, 0x46, 0x2D }; // "%PDF-"
|
||||
|
||||
private static IConfiguration Config(Dictionary<string, string?>? overrides = null)
|
||||
{
|
||||
@@ -51,6 +53,7 @@ public class SystemStatusServiceTests
|
||||
Mock<IBlobStorageService>? blob = null,
|
||||
Mock<IMfrClientFactory>? mfr = null,
|
||||
Mock<IComService>? com = null,
|
||||
Mock<IPdfService>? pdf = null,
|
||||
ProcessWebComSettings? mailer = null,
|
||||
FuchsEmailSettings? email = null,
|
||||
AzureBlobStorageSettings? blobSettings = null,
|
||||
@@ -67,6 +70,16 @@ public class SystemStatusServiceTests
|
||||
.ReturnsAsync(new MailerCredentialsCheckResult { Ok = true, Account = "acc" });
|
||||
}
|
||||
|
||||
// Default PDF stub: renders any letter to a fixed, recognisable byte payload.
|
||||
var pdfMock = pdf ?? new Mock<IPdfService>();
|
||||
if (pdf is null)
|
||||
{
|
||||
pdfMock.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
|
||||
.ReturnsAsync(new MigraDoc.DocumentObjectModel.Document());
|
||||
pdfMock.Setup(p => p.DocToPdfBytes(It.IsAny<MigraDoc.DocumentObjectModel.Document>()))
|
||||
.Returns(FakePdfBytes);
|
||||
}
|
||||
|
||||
return new SystemStatusService(
|
||||
config ?? Config(),
|
||||
env.Object,
|
||||
@@ -74,6 +87,7 @@ public class SystemStatusServiceTests
|
||||
(blob ?? new Mock<IBlobStorageService>()).Object,
|
||||
(mfr ?? new Mock<IMfrClientFactory>()).Object,
|
||||
comMock.Object,
|
||||
pdfMock.Object,
|
||||
Options.Create(mailer ?? new ProcessWebComSettings { Enabled = true, Token = "tok", BaseUrl = "https://api.example", AccountId = "acc", ServerId = "srv" }),
|
||||
Options.Create(email ?? new FuchsEmailSettings()),
|
||||
Options.Create(blobSettings ?? new AzureBlobStorageSettings { Enabled = true }),
|
||||
@@ -495,6 +509,140 @@ public class SystemStatusServiceTests
|
||||
Assert.Null(r.OverrideRecipient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_AttachPdfDefault_AttachesRenderedPdfUnderFixedName()
|
||||
{
|
||||
Dictionary<string, byte[]>? capturedAttachments = null;
|
||||
FuchsPdf.FdsTextBlocks? capturedBlocks = null;
|
||||
var com = new Mock<IComService>();
|
||||
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
|
||||
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?, string?>(
|
||||
(_, _, _, _, _, att, _) => capturedAttachments = att)
|
||||
.ReturnsAsync(true);
|
||||
var pdf = new Mock<IPdfService>();
|
||||
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), false))
|
||||
.Callback<FuchsPdf.FdsTextBlocks, bool>((tb, _) => capturedBlocks = tb)
|
||||
.ReturnsAsync(new MigraDoc.DocumentObjectModel.Document());
|
||||
pdf.Setup(p => p.DocToPdfBytes(It.IsAny<MigraDoc.DocumentObjectModel.Document>())).Returns(FakePdfBytes);
|
||||
var svc = Build(com: com, pdf: pdf);
|
||||
|
||||
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Zeile1\r\nZeile2");
|
||||
|
||||
Assert.True(r.Sent);
|
||||
Assert.Equal(SystemStatusService.TestPdfAttachmentName, r.AttachmentName);
|
||||
Assert.Contains(SystemStatusService.TestPdfAttachmentName, r.Message);
|
||||
Assert.NotNull(capturedAttachments);
|
||||
var (name, bytes) = Assert.Single(capturedAttachments!);
|
||||
Assert.Equal(SystemStatusService.TestPdfAttachmentName, name);
|
||||
Assert.Equal(FakePdfBytes, bytes);
|
||||
// The letter carries the mail's own subject/body and addresses the requested recipient.
|
||||
Assert.NotNull(capturedBlocks);
|
||||
Assert.Equal("Betreff", capturedBlocks!.Subject);
|
||||
Assert.Equal("Zeile1\nZeile2", capturedBlocks.Body);
|
||||
Assert.Contains("dest@example.test", capturedBlocks.Address);
|
||||
pdf.Verify(p => p.DocToPdfBytes(It.IsAny<MigraDoc.DocumentObjectModel.Document>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_AttachPdfFalse_SendsWithoutAttachmentAndSkipsRendering()
|
||||
{
|
||||
Dictionary<string, byte[]>? capturedAttachments = new();
|
||||
var com = new Mock<IComService>();
|
||||
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
|
||||
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?, string?>(
|
||||
(_, _, _, _, _, att, _) => capturedAttachments = att)
|
||||
.ReturnsAsync(true);
|
||||
var pdf = new Mock<IPdfService>(MockBehavior.Strict); // any render call would throw
|
||||
var svc = Build(com: com, pdf: pdf);
|
||||
|
||||
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body", attachPdf: false);
|
||||
|
||||
Assert.True(r.Sent);
|
||||
Assert.Null(r.AttachmentName);
|
||||
Assert.Null(capturedAttachments);
|
||||
Assert.DoesNotContain("PDF", r.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_PdfRenderingFails_DoesNotSendAndReportsError()
|
||||
{
|
||||
var com = new Mock<IComService>();
|
||||
var pdf = new Mock<IPdfService>();
|
||||
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
|
||||
.ThrowsAsync(new InvalidOperationException("font resolver missing"));
|
||||
var svc = Build(com: com, pdf: pdf);
|
||||
|
||||
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body");
|
||||
|
||||
Assert.False(r.Sent);
|
||||
Assert.Null(r.AttachmentName);
|
||||
Assert.Contains("PDF-Anhang", r.Message);
|
||||
Assert.Contains("font resolver missing", r.Message);
|
||||
Assert.Equal("dest@example.test", r.RequestedRecipient);
|
||||
com.Verify(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_MailerRejectsWithAttachment_ReportsAttachmentNotConfirmed()
|
||||
{
|
||||
var com = new Mock<IComService>();
|
||||
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(false); // ADR 0028: also the outcome when attachmentsProcessed ≠ sent
|
||||
var svc = Build(com: com);
|
||||
|
||||
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body");
|
||||
|
||||
Assert.False(r.Sent);
|
||||
Assert.Equal(SystemStatusService.TestPdfAttachmentName, r.AttachmentName);
|
||||
Assert.Contains("Anhang", r.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_RealLetterRenderer_ProducesValidOnePagePdfAttachment()
|
||||
{
|
||||
// Real MigraDoc/PdfSharp rendering of the letter the service builds (raw, without the
|
||||
// Spire PDF/A pass, so the test does not depend on a Spire license) — proves the text
|
||||
// blocks the service assembles are renderable, not just that a stub was called.
|
||||
Dictionary<string, byte[]>? capturedAttachments = null;
|
||||
var com = new Mock<IComService>();
|
||||
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
|
||||
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?, string?>(
|
||||
(_, _, _, _, _, att, _) => capturedAttachments = att)
|
||||
.ReturnsAsync(true);
|
||||
var pdf = new Mock<IPdfService>();
|
||||
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
|
||||
.Returns<FuchsPdf.FdsTextBlocks, bool>((tb, draft) => FuchsPdf.WriteLetter(tb, draft, FuchsPdf.DeCulture));
|
||||
pdf.Setup(p => p.DocToPdfBytes(It.IsAny<MigraDoc.DocumentObjectModel.Document>()))
|
||||
.Returns<MigraDoc.DocumentObjectModel.Document>(FuchsPdf.DocToPdfBytesRaw);
|
||||
var svc = Build(com: com, pdf: pdf);
|
||||
|
||||
var r = await svc.SendTestEmailAsync("dest@example.test", "Fuchs Intranet – Test-E-Mail", "Dies ist eine Test-E-Mail.\nZweite Zeile.");
|
||||
|
||||
Assert.True(r.Sent);
|
||||
byte[] bytes = Assert.Single(capturedAttachments!).Value;
|
||||
Assert.True(bytes.Length > 1000, "attachment should be a real rendered PDF");
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(bytes, 0, 4));
|
||||
using var opened = PdfSharp.Pdf.IO.PdfReader.Open(new System.IO.MemoryStream(bytes), PdfSharp.Pdf.IO.PdfDocumentOpenMode.Import);
|
||||
Assert.Equal(1, opened.PageCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_MissingRequiredFields_DoesNotRenderPdf()
|
||||
{
|
||||
var pdf = new Mock<IPdfService>(MockBehavior.Strict);
|
||||
var svc = Build(pdf: pdf);
|
||||
|
||||
var r = await svc.SendTestEmailAsync("", "Betreff", "Body");
|
||||
|
||||
Assert.False(r.Sent);
|
||||
Assert.Null(r.AttachmentName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_OverrideActive_ReportsOverrideRecipient()
|
||||
{
|
||||
|
||||
@@ -69,13 +69,34 @@ public partial class IntranetController
|
||||
{
|
||||
if (!HasForm("to", "subject"))
|
||||
return BadRequest400();
|
||||
// attachpdf is the dialog's Ja/Nein select ("true"/"false"); absent → attach (default).
|
||||
bool attachPdf = !bool.TryParse(Form("attachpdf"), out bool parsed) || parsed;
|
||||
var result = await status.SendTestEmailAsync(
|
||||
Form("to"), Form("subject"), Form("body"), HttpContext.RequestAborted);
|
||||
_logger.LogInformation("Admin test email requested by user={User} to={To} sent={Sent}",
|
||||
UserAccountID, result.RequestedRecipient, result.Sent);
|
||||
Form("to"), Form("subject"), Form("body"), attachPdf, HttpContext.RequestAborted);
|
||||
_logger.LogInformation("Admin test email requested by user={User} to={To} attachment={Attachment} sent={Sent}",
|
||||
UserAccountID, result.RequestedRecipient, result.AttachmentName, result.Sent);
|
||||
return AdminJson(new { result });
|
||||
}
|
||||
|
||||
// Maintenance action, reachable only from the admin-gated "Specialties" page
|
||||
// (IntranetController.Specialties.cs / fis.specialties.js): backs up an invoice's
|
||||
// stored PDF and clears it so the document regenerates on next read (ADR 0025 /
|
||||
// Docs/Decisions/0027). GET-safe crawlers/prefetchers must never trigger this, so it
|
||||
// requires POST despite the rest of the Admin module tolerating either verb.
|
||||
case "backupclearinvoicefile":
|
||||
{
|
||||
if (!HttpContext.Request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase))
|
||||
return BadRequest400();
|
||||
string invoiceId = string.IsNullOrWhiteSpace(code) ? Form("id") : code;
|
||||
if (string.IsNullOrWhiteSpace(invoiceId))
|
||||
return BadRequest400();
|
||||
var backup = await _invoiceFileBackup.BackupAndClearAsync(invoiceId, UserAccountID, DbSec, HttpContext.RequestAborted);
|
||||
_logger.LogWarning(
|
||||
"Admin invoice-file backup/clear requested by user={User} invoiceId={InvoiceId} success={Success} reason={Reason}",
|
||||
UserAccountID, invoiceId, backup.Success, backup.Reason);
|
||||
return AdminJson(new { backup });
|
||||
}
|
||||
|
||||
default:
|
||||
_logger.LogWarning("Admin: no handler for action={Action}, user={User}", id, UserAccountID);
|
||||
return BadRequest400();
|
||||
|
||||
@@ -155,8 +155,11 @@ public partial class IntranetController
|
||||
{
|
||||
var remdoc = new Dictionary<string, byte[]>
|
||||
{ [frdic.nz("DocumentName", "").ne("Zahlungserinnerung.pdf")] = filebyte };
|
||||
// The invoice copy is optional: an empty varbinary (invoice file never stored) must
|
||||
// not attach a 0-byte file — the mailer refuses empty attachments and the whole
|
||||
// reminder would fail instead of going out without the copy.
|
||||
if (!string.IsNullOrEmpty(frdic.nz("InvoiceFileName")) &&
|
||||
frdic.no("InvoiceFile", null!) is byte[] invFile)
|
||||
frdic.no("InvoiceFile", null!) is byte[] { Length: > 0 } invFile)
|
||||
remdoc[frdic.nz("InvoiceFileName")] = invFile;
|
||||
|
||||
_logger.LogInformation("Reminder conf: emailing finalized reminder {RemId} to {Email} user={User}",
|
||||
@@ -247,8 +250,11 @@ public partial class IntranetController
|
||||
{
|
||||
var remdoc = new Dictionary<string, byte[]>
|
||||
{ [frdic.nz("DocumentName", "").ne("Zahlungserinnerung.pdf")] = filebyte };
|
||||
// The invoice copy is optional: an empty varbinary (invoice file never stored) must
|
||||
// not attach a 0-byte file — the mailer refuses empty attachments and the whole
|
||||
// reminder would fail instead of going out without the copy.
|
||||
if (!string.IsNullOrEmpty(frdic.nz("InvoiceFileName")) &&
|
||||
frdic.no("InvoiceFile", null!) is byte[] invFile)
|
||||
frdic.no("InvoiceFile", null!) is byte[] { Length: > 0 } invFile)
|
||||
remdoc[frdic.nz("InvoiceFileName")] = invFile;
|
||||
bool sent = await _comService.SendEmailAsync($"rem_{remId}",
|
||||
$"SanitärFuchs - {frdic.nz("subject").ne(frdic.nz("DocumentName"))}",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using static OCORE.web.mvc_helper_async;
|
||||
|
||||
namespace Fuchs.Controllers;
|
||||
|
||||
// Partial class: "Specialties" module — the admin page (URL /specialties, menu entry under
|
||||
// Administration) that surfaces the otherwise hidden maintenance actions as small card forms.
|
||||
//
|
||||
// This partial only answers the module's auth probe; the cards call the real action endpoints
|
||||
// directly (e.g. POST /do/admin/backupclearinvoicefile/<id> in IntranetController.Admin.cs),
|
||||
// each of which enforces the same fds_sys > 4 gate server-side. See
|
||||
// Docs/Concepts/admin-specialties.md.
|
||||
public partial class IntranetController
|
||||
{
|
||||
private async Task<IActionResult> Do_Process_Specialties(string fn, string id, string code)
|
||||
{
|
||||
_logger.LogDebug("Do_Process_Specialties action={Action} user={User}", id, UserAccountID);
|
||||
|
||||
int authLevel = await GetSystemAdminAuthAsync(fn, id, code);
|
||||
bool authorized = authLevel > AdminMinAuthExclusive;
|
||||
|
||||
if (id.Equals("auth", StringComparison.OrdinalIgnoreCase))
|
||||
return await JSONAsync(new { manage = authorized ? 1 : 0, level = authLevel });
|
||||
|
||||
if (!authorized)
|
||||
{
|
||||
_logger.LogWarning("Specialties access denied for user={User} (fds_sys={Level}) action={Action}",
|
||||
UserAccountID, authLevel, id);
|
||||
return Unauthorized401();
|
||||
}
|
||||
|
||||
_logger.LogWarning("Specialties: no handler for action={Action}, user={User}", id, UserAccountID);
|
||||
return BadRequest400();
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
private readonly IWidgetService _widgets;
|
||||
private readonly IReportService _reports;
|
||||
private readonly IInvoiceService _invoices;
|
||||
private readonly IInvoiceFileBackupService _invoiceFileBackup;
|
||||
private readonly IReminderService _reminders;
|
||||
private readonly IEventService _events;
|
||||
private readonly IInvoiceDraftService _invoiceDrafts;
|
||||
@@ -65,6 +66,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
IWidgetService widgets,
|
||||
IReportService reports,
|
||||
IInvoiceService invoices,
|
||||
IInvoiceFileBackupService invoiceFileBackup,
|
||||
IReminderService reminders,
|
||||
IEventService events,
|
||||
IInvoiceDraftService invoiceDrafts,
|
||||
@@ -82,6 +84,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
_widgets = widgets;
|
||||
_reports = reports;
|
||||
_invoices = invoices;
|
||||
_invoiceFileBackup = invoiceFileBackup;
|
||||
_reminders = reminders;
|
||||
_events = events;
|
||||
_invoiceDrafts = invoiceDrafts;
|
||||
@@ -185,6 +188,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
"rep" => await Do_Process_Reports(fn, id, code),
|
||||
"bam" => await Do_Process_Bankings(fn, id, code),
|
||||
"admin" => await Do_Process_Admin(fn, id, code),
|
||||
"specialties" => await Do_Process_Specialties(fn, id, code),
|
||||
"usr" => await Do_Process_AdminUsers(fn, id, code),
|
||||
"usrgrp" => await Do_Process_AdminGroups(fn, id, code),
|
||||
"auth" => await HandleAuth(fn, id, code),
|
||||
|
||||
@@ -196,8 +196,8 @@ Cookie-based authentication (`CookieAuthenticationDefaults`) with custom claims
|
||||
|
||||
> **Status: DONE.** The services below are implemented and DI-registered in
|
||||
> `Program.cs`. The original extraction rationale is retained for reference /
|
||||
> history. `FuchsFdsEmail` → `IComService` (ProcessWeb Mailer API, inline
|
||||
> base64 attachments), `FuchsWidgets` → `IWidgetService`, `FuchsPdf` →
|
||||
> history. `FuchsFdsEmail` → `IComService` (ProcessWeb Mailer API, multipart
|
||||
> attachments — see `Docs/Concepts/processweb-mailer-interface.md`), `FuchsWidgets` → `IWidgetService`, `FuchsPdf` →
|
||||
> `IPdfService`, `Banking` → `IBankingService` (now MT940 **and** CAMT),
|
||||
> `FdsInvoiceData`/`FdsReminderData` → `IInvoiceService`/`IReminderService`
|
||||
> (data classes are now pure POCOs), `FuchsReports` → `IReportService`
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
status: Active
|
||||
lastUpdated: 2026-09-17
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/IntranetController.Specialties.cs"
|
||||
- "Fuchs/js/intranet/modules/fis.specialties*"
|
||||
- "Fuchs/js/intranet/fis_main_menu.js"
|
||||
- "Fuchs/js/intranet/fis_main_go.js"
|
||||
relatedDecisions:
|
||||
- "0027-admin-invoice-file-backup-and-clear.md"
|
||||
---
|
||||
|
||||
# Admin "Specialties" page (`/specialties`)
|
||||
|
||||
## Summary
|
||||
|
||||
An admin-only page that surfaces the otherwise hidden maintenance actions as
|
||||
cards, each a small form. It exists so support does not need SSMS or a
|
||||
hand-crafted POST for actions like resetting an invoice's stored PDF. It is
|
||||
a regular lazily loaded frontend module (`fn = specialties`), gated like the
|
||||
rest of the Administration menu (`fds_sys > 4`), and reachable both from the
|
||||
menu (Administration → Spezialfunktionen) and by opening the URL
|
||||
`/specialties` directly.
|
||||
|
||||
## How it works
|
||||
|
||||
- **Module load** follows the standard contract (see
|
||||
`admin-system-status.md`): the menu item `init:specialties` →
|
||||
`$ocms.init('specialties')` → `POST /do/specialties/auth` → when
|
||||
`manage > 0`, import `web/fis.specialties.de.js` + `fis.specialties.css`
|
||||
and call `init2`. `IntranetController.Specialties.cs` answers only that
|
||||
auth probe (same `GetSystemAdminAuthAsync` gate as the Admin module) and
|
||||
401s everything else.
|
||||
- **Deep link.** `IntranetController.Index` renders the same SPA shell for
|
||||
every `/{fn}` URL and ignores `fn`; there is no client router. `fis_main_go.js`
|
||||
therefore calls `$fis.openModuleFromPath()` once the admin menu has settled:
|
||||
it takes the first path segment and calls `$ocms.init(fn)` **only if the
|
||||
user's menu offers an `init:<fn>` item** (admin dropdown included). A
|
||||
non-admin opening `/specialties` sees the dashboard, as before; arbitrary
|
||||
paths never trigger a module load.
|
||||
- **Cards.** `fis.specialties.js` keeps an array `cards` of builder
|
||||
functions. Each card is `title + description + <form> + result box`;
|
||||
`$spc.submit(c, { button, confirm, run, report })` wires HTML5 validation,
|
||||
a `confirm()`, the `postXT` call (`run(values)` returns the request) and
|
||||
the inline result (`report(response)` → `{ ok, lines }`). The card calls
|
||||
the action's **real** endpoint — every such endpoint enforces the admin
|
||||
gate itself; the page adds no authority.
|
||||
- **Current cards**
|
||||
- *Rechnungsdatei zurücksetzen (Soft-Delete)* → `POST
|
||||
/do/admin/backupclearinvoicefile/<internal Id>` (ADR 0027). Shows the
|
||||
outcome and the blob backup URI, or the German text for the service's
|
||||
`reason` (`invoice_not_found`, `not_final`, `external`, `no_file`,
|
||||
`db_error`). An invoice without a stored file (`hasFile` from
|
||||
`fds__getInvoice`) is answered `no_file` before any blob copy, so
|
||||
repeated clicks leave no spare `backup_*` blobs.
|
||||
- **Adding a card:** append a builder to `cards`, add its texts to
|
||||
`fis.specialties_txt_de.js`, run `npx gulp min`. No server change is
|
||||
needed unless the action itself is new.
|
||||
- **Build:** bundle entries in `Fuchs/bdlconfig.json`
|
||||
(`intranet:specialties`); generated files under `wwwroot/web/`.
|
||||
|
||||
## Key files
|
||||
|
||||
- `Fuchs/Controllers/IntranetController.Specialties.cs` — auth probe.
|
||||
- `Fuchs/Controllers/IntranetController.cs` — `"specialties"` case in `Do`.
|
||||
- `Fuchs/js/intranet/modules/fis.specialties.js` / `_txt_de.js` / `.scss`.
|
||||
- `Fuchs/js/intranet/fis_main_menu.js` — menu item + `openModuleFromPath`.
|
||||
- `Fuchs/js/intranet/fis_main_go.js` — deep link after menu settles.
|
||||
- `Fuchs/js/intranet/fis_texts_gui_de.js` — `m_specialties` label.
|
||||
|
||||
## Related decisions
|
||||
|
||||
- [0027 — Admin invoice-file backup-and-clear action](../Decisions/0027-admin-invoice-file-backup-and-clear.md)
|
||||
@@ -47,7 +47,7 @@ click "Administration" → $ocms.init('admin')
|
||||
$ocms.admin.init2() → init3() renders the page
|
||||
POST /do/admin/status → { info, probes } full snapshot + all probes
|
||||
per-card "Aktualisieren" → POST /do/admin/probe/<component> → { probe }
|
||||
"Test-E-Mail senden" → POST /do/admin/testmail (to/subject/body) → { result }
|
||||
"Test-E-Mail senden" → POST /do/admin/testmail (to/subject/body/attachpdf) → { result }
|
||||
```
|
||||
|
||||
### Backend (`ISystemStatusService` / `SystemStatusService`, scoped)
|
||||
@@ -60,7 +60,7 @@ click "Administration" → $ocms.init('admin')
|
||||
(`status` = `ok`/`error`/`disabled`/`unconfigured`, `ok`, `message`, `detail`, `durationMs`,
|
||||
optional `metrics`). They never throw — failures are captured in the result. `database` runs
|
||||
`SELECT @@SERVERNAME, DB_NAME(), SUSER_SNAME()`; `email` calls
|
||||
`IComService.VerifyCredentialsAsync` (GET `{BaseUrl}/mailer/fc?fn=verify` with the configured
|
||||
`IComService.VerifyCredentialsAsync` (GET `{BaseUrl}/api/mailer/verify` with the configured
|
||||
Basic-auth account/token — see the Mailer API contract) and only turns `ok` when the Mailer API
|
||||
itself confirms the credentials (`ok:true`); when `ProcessWebComSettings.ServerId` is configured
|
||||
it is sent along as `&serverid=...` so the API also confirms this server belongs to the account
|
||||
@@ -74,9 +74,18 @@ click "Administration" → $ocms.init('admin')
|
||||
count** for the invoice/reminder containers, surfaced as `metrics` and totalled in the message);
|
||||
`mfr` calls `IMfrClientFactory.Create().GetEntities()`. The generic `metrics` (label/value pairs)
|
||||
is how a probe reports extra facts for display — the blob probe uses it for the file counts.
|
||||
- `SendTestEmailAsync(to, subject, body)` HTML-encodes the body and sends via `IComService`, so
|
||||
the `Fuchs:Email:OverrideRecipient` redirect applies exactly as for any other mail; the result
|
||||
reports the requested recipient and, when active, the override target.
|
||||
- `SendTestEmailAsync(to, subject, body, attachPdf)` HTML-encodes the body and sends via
|
||||
`IComService`, so the `Fuchs:Email:OverrideRecipient` redirect applies exactly as for any other
|
||||
mail; the result reports the requested recipient and, when active, the override target.
|
||||
With `attachPdf` (dialog select "PDF-Anhang mitsenden", default *Ja*; absent form value ⇒ on)
|
||||
a one-page letter PDF (`Fuchs-Intranet-Testmail.pdf`) is rendered in memory via
|
||||
`IPdfService.WriteLetterAsync` + `DocToPdfBytes` — standard Fuchs letterhead, the recipient in
|
||||
the address window, `Datum`/`Umgebung`/`Nummer=admin_test` in the admin block, the mail's own
|
||||
subject and body as the letter text; no DB or stored document is touched. This exercises the
|
||||
Mailer's multipart/attachment path, so per ADR 0028 `sent` is only `true` when the Mailer also
|
||||
confirmed the attachment (`attachmentsProcessed == 1`); the result's `attachmentName` says
|
||||
whether an attachment was part of the send. A PDF rendering failure is reported in the result
|
||||
(`sent=false`, message names the error) and nothing is sent.
|
||||
- Every probe emits the `fuchs.systemstatus.probes` counter tagged by component + status and an
|
||||
`systemstatus.probe` activity span.
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
status: Active
|
||||
lastUpdated: 2026-09-17
|
||||
applyTo:
|
||||
- "Fuchs/Services/IComService.cs"
|
||||
- "Fuchs/Services/ProcessWebComService.cs"
|
||||
- "Fuchs/Services/ProcessWebComSettings.cs"
|
||||
- "Fuchs/Services/FuchsMailerClient.cs"
|
||||
- "Fuchs/Services/MailerAttachmentPolicy.cs"
|
||||
- "Fuchs.Tests/ProcessWebComServiceTests.cs"
|
||||
- "Fuchs.Tests/FuchsMailerClientTests.cs"
|
||||
relatedDecisions:
|
||||
- "0028-mailer-push-com-multipart-and-attachment-confirmation.md"
|
||||
- "0029-outbound-mail-via-ocore-pwmailer-client.md"
|
||||
---
|
||||
|
||||
# ProcessWeb Mailer interface (outbound email/SMS)
|
||||
|
||||
## Summary
|
||||
|
||||
All outbound email and SMS leaves Fuchs through `IComService` →
|
||||
`ProcessWebComService`, which is a thin adapter over the OCORE_web
|
||||
**`IPWMailerClientService`** (`OCORE_web/comm/pwmailer/`). The client owns
|
||||
the HTTP contract of the ProcessWeb Mailer API; Fuchs owns what is specific
|
||||
to Fuchs — the recipient-override safety net, the signature, the attachment
|
||||
limits, the `fds__emaillog` audit row and telemetry.
|
||||
|
||||
The authoritative, server-side contract is the Mailer's own function catalog:
|
||||
|
||||
> **https://processweb.de/api/mailer/fc** — subject to change as features are
|
||||
> added, but kept backward compatible. The OCORE_web client is kept in sync
|
||||
> with it (its header says so and names the server-side files); read both
|
||||
> before changing anything here.
|
||||
|
||||
## How it works
|
||||
|
||||
### Layering
|
||||
|
||||
| Layer | File | Responsibility |
|
||||
|-------|------|----------------|
|
||||
| Adapter | `Fuchs/Services/ProcessWebComService.cs` | `IComService`: validate recipient, apply `Fuchs:Email:OverrideRecipient`, append signature, build a `PWMailerMessage`, interpret the `PWMailerResult`, write `fds__emaillog`, emit metrics |
|
||||
| Fuchs client | `Fuchs/Services/FuchsMailerClient.cs` | `PWMailerClientService` subclass; overrides `ValidateMessage` to add the attachment limits (`MailerAttachmentPolicy`) on top of the contract's base rules |
|
||||
| OCORE client | `OCORE_web/comm/pwmailer/PWMailerClientService*.cs` | endpoints, envelope, JSON vs multipart (unnamed envelope field + one file part per attachment), `attachmentCount`, Basic auth, token resolution, `x-pcw-excd` error headers, retries/timeouts, typed results |
|
||||
|
||||
### Configuration
|
||||
|
||||
`Fuchs:Mailer` (`ProcessWebComSettings`) stays the single config section. In
|
||||
`Program.cs` it is mapped onto `PWMailerClientOptions`:
|
||||
|
||||
- `BaseUrl` (`https://processweb.de`) + `/api/mailer` → `PWMailerClientOptions.BaseUrl`
|
||||
- `AccountId` → `Account`, `ServerId` → `ServerId`, `Token` → `Token`
|
||||
- `TokenSecretName = "Fuchs--Mailer--Token"` — the same Key Vault secret
|
||||
OCORE secret management already maps into `Fuchs:Mailer:Token`. In
|
||||
production the configuration value is therefore already the plain token;
|
||||
the name only matters when the `MANAGED_BY_KEYVAULT` placeholder is still
|
||||
in place (e.g. locally), where the client loads it on demand.
|
||||
- `Attachments` (`MaxFileBytes` 10 MB, `MaxMessageBytes` 20 MB, `MaxCount`
|
||||
10 — the contract defaults) feed `FuchsMailerClient`'s pre-validation.
|
||||
|
||||
Registration: `AddPWMailerClient<FuchsMailerClient>()` (singleton, named
|
||||
`HttpClient` `PWMailerClient`). `ProcessWebComService` stays scoped.
|
||||
|
||||
### Sending an email
|
||||
|
||||
1. `SendEmailAsync` validates the address, applies the override, appends the
|
||||
signature, and builds `PWMailerMessage { To, Cc, Subject, Body, Reference,
|
||||
ComType = "e", Attachments }`. `AttachmentCount` is left `null`, so the
|
||||
client sends the real count and the Mailer cross-checks it (400 on
|
||||
mismatch).
|
||||
2. `FuchsMailerClient.ValidateMessage` runs *inside* `PushAsync` before any
|
||||
bytes go out: base rules (server id, recipient, subject, body, empty file,
|
||||
missing extension) plus Fuchs limits (count, per-file size, total size,
|
||||
forbidden extensions — `MailerAttachmentPolicy.ForbiddenExtensions` is the
|
||||
contract's quoted subset; the server's list is longer and authoritative).
|
||||
A violation returns a result with `StatusCode 0`, `Attempts 0` and the
|
||||
`"; "`-joined rule messages as `Error`.
|
||||
3. The client posts JSON (no attachments) or multipart (with) to
|
||||
`POST /api/mailer/push_com`, never retrying a push.
|
||||
4. The adapter interprets the `PWMailerResult<PWMailerPushReceipt>`:
|
||||
|
||||
| Result | `SendEmailAsync` | audit `fds__emaillog` | `fuchs.emails.failed{reason}` |
|
||||
|--------|------------------|-----------------------|-------------------------------|
|
||||
| `Success` and (no attachments or `AttachmentsProcessed == sent`) | `true` | `guid = comStackID` | — (`fuchs.emails.sent`) |
|
||||
| `Success` but `AttachmentsProcessed != sent` | `false` | counts in log, `guid = comStackID` | `attachments-mismatch` |
|
||||
| `IsConflict` (409: identical message still pending at the Mailer — it will be sent, nothing new queued) | `true` | "Duplicate of a still-pending entry (409) …" | — (`fuchs.emails.sent`) |
|
||||
| `StatusCode 0`, `Attempts 0`, `Exception is ArgumentException` (refused by pre-validation) | `false` | one line per rule | `validation` |
|
||||
| `StatusCode 0`, `Attempts 0`, no exception (options unusable / token not resolvable, e.g. Key Vault outage) | `false` | `Mailer client not usable: …` (Error log) | `client-config` |
|
||||
| any other failure | `false` | `API error {status}: {error}` | `api-error` |
|
||||
| client threw | `false` | exception detail | `api-error` |
|
||||
|
||||
`reference` (`inv_<Id>` etc.) is the audit key and part of the Mailer's
|
||||
idempotency key (server + reference + content, while the first entry is still
|
||||
pending). A resend of the identical mail within that window answers 409 and
|
||||
is reported as **success** — the first entry is delivered; nothing is sent
|
||||
twice. A resend after delivery started is a new entry. Callers keep reusing
|
||||
`inv_<Id>` (e.g. `req/resend`); no per-attempt reference is needed.
|
||||
|
||||
### SMS and verification
|
||||
|
||||
- `SendSmsAsync` pushes `PWMailerMessage { ComType = "s", To = mobile, Body }`
|
||||
— the base validation requires no subject for `comType "s"`.
|
||||
- `VerifyCredentialsAsync` → `IPWMailerClientService.VerifyAsync(serverId)`
|
||||
(`GET /api/mailer/verify[?serverid=…]`), mapped to
|
||||
`MailerCredentialsCheckResult` for the Admin system-status view.
|
||||
|
||||
### Observability
|
||||
|
||||
`fuchs.emails.sent` / `fuchs.emails.failed{reason}` with reasons
|
||||
`invalid-email`, `disabled`, `validation`, `client-config`,
|
||||
`attachments-mismatch`, `api-error`; `fuchs.email.send.duration{success}`. The OCORE
|
||||
client additionally logs every failed call at Warning and exposes
|
||||
`RequestSending`/`ResponseReceived`/`CallCompleted`/`CallFailed` events (not
|
||||
subscribed by Fuchs today).
|
||||
|
||||
## Key files
|
||||
|
||||
- `Fuchs/Services/IComService.cs` — interface + `MailerCredentialsCheckResult`.
|
||||
- `Fuchs/Services/ProcessWebComService.cs` — the adapter.
|
||||
- `Fuchs/Services/FuchsMailerClient.cs` — `ValidateMessage` override.
|
||||
- `Fuchs/Services/MailerAttachmentPolicy.cs` — limit rules (pure).
|
||||
- `Fuchs/Services/ProcessWebComSettings.cs` — `Fuchs:Mailer` incl. `Attachments`.
|
||||
- `Fuchs/Program.cs` — options mapping + `AddPWMailerClient<FuchsMailerClient>()`.
|
||||
- `OCORE_web/OCORE_web/comm/pwmailer/` — the client (submodule).
|
||||
- `Fuchs.Tests/ProcessWebComServiceTests.cs`, `Fuchs.Tests/FuchsMailerClientTests.cs`;
|
||||
the wire format is tested in `OCORE_web/OCORE_webTests/comm/PWMailerClientServiceTests.cs`.
|
||||
|
||||
## Related decisions
|
||||
|
||||
- [0028 — push_com multipart attachments and attachment confirmation](../Decisions/0028-mailer-push-com-multipart-and-attachment-confirmation.md)
|
||||
- [0029 — Outbound mail via the OCORE_web PWMailer client](../Decisions/0029-outbound-mail-via-ocore-pwmailer-client.md)
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-17
|
||||
applyTo:
|
||||
- "Fuchs/Controllers/IntranetController.Admin.cs"
|
||||
- "Fuchs/Services/IInvoiceFileBackupService.cs"
|
||||
- "Fuchs/Services/InvoiceFileBackupService.cs"
|
||||
- "Fuchs/Services/IBlobStorageService.cs"
|
||||
- "Fuchs/Services/AzureBlobStorageService.cs"
|
||||
- "Fuchs_Database/dbo/Stored Procedures/fds__admin_backupAndClearInvoiceFile.sql"
|
||||
- "Fuchs_Database/dbo/Tables/fds__invoice_file_backups.sql"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0027 — Admin invoice-file backup-and-clear action
|
||||
|
||||
## Context
|
||||
|
||||
Support occasionally needs to force a finalised invoice's PDF to be regenerated
|
||||
(e.g. it was rendered with wrong data before a fix, or is otherwise corrupt).
|
||||
Since [0025](0025-invoice-document-regenerate-on-miss.md), clearing
|
||||
`fds__invoices.[file]` on a finalised, non-external invoice is enough to make
|
||||
the next `req/idoc`/`inv/rdoc` read regenerate and re-persist it.
|
||||
|
||||
A manual maintenance proc for this already exists,
|
||||
`[dbo].[_BackupAndClearInvoiceFile]`, but its own header comment states it is
|
||||
"Manual-only maintenance utility. Never called from application code", and
|
||||
0025's "Alternatives considered" explicitly rejected relying on it from the
|
||||
app, partly because it leaves no application log entry. We still want this
|
||||
capability reachable from the app (a hidden, admin-gated action) rather than
|
||||
requiring direct SSMS access, without contradicting that standing decision.
|
||||
|
||||
Additionally, `IBlobStorageService` archives invoice PDFs to Azure Blob
|
||||
Storage under a fixed per-invoice blob name (`{invoiceId}/{fileName}`) and
|
||||
always uploads with `overwrite: true`. If a cleared invoice's document is
|
||||
later regenerated (0025), `StoreInvoiceDocumentFileAsync` re-uploads under
|
||||
that same blob name — silently overwriting the very copy this action is
|
||||
meant to preserve as a backup, unless it is moved first.
|
||||
|
||||
## Decision
|
||||
|
||||
- A **new** proc, `[dbo].[fds__admin_backupAndClearInvoiceFile]`, is added
|
||||
rather than calling `_BackupAndClearInvoiceFile`. It copies the invoice's
|
||||
current `[file]` bytes into a new table, `[dbo].[fds__invoice_file_backups]`
|
||||
(one row per backup, not a timestamp-named ad-hoc table per call), then
|
||||
clears `[file]` in the same transaction (`XACT_ABORT` + TRY/CATCH with
|
||||
rollback and re-raise, so a clear can never commit without its backup row).
|
||||
It logs through the normal
|
||||
`fds__admin_activity` insert (matching `fds__setInvoiceFile`'s pattern) and
|
||||
every call is also logged through the app's `ILogger`/DB-log-sink pipeline
|
||||
(ADR 0024) — unlike the manual proc, this path is fully auditable.
|
||||
`_BackupAndClearInvoiceFile` is left completely untouched as the manual
|
||||
break-glass tool for cases the app can't reach (e.g. a corrupt row the
|
||||
admin UI can't load).
|
||||
- The action applies 0025's regeneration precondition **before** touching
|
||||
anything: only a **finalised, non-external** invoice qualifies. Both the
|
||||
service (`IsDraft`/`IsExternal`) and the proc (`[DateFinalized] IS NOT NULL
|
||||
AND ISNULL([IsExternal],0) = 0`) enforce it. Clearing a draft's `[file]`
|
||||
would let the next read persist a draft render through
|
||||
`fds__setInvoiceFile` (`WHERE [file] IS NULL`), which then blocks the real
|
||||
final PDF forever — the exact defect 0025 calls load-bearing; an external
|
||||
(MFR) document has no template and would just become a permanent 404.
|
||||
- `IBlobStorageService` gains `CopyInvoicePdfToBackupAsync`: a
|
||||
**server-side copy** (`StartCopyFromUriAsync`, bytes never pass through
|
||||
the app) of the existing blob to a timestamped backup name in the same
|
||||
container — the original blob is **kept**, not renamed. A private source in
|
||||
the same storage account is authorised by the request's own Shared Key
|
||||
credential, so no SAS is generated. The copy runs *before* the DB clear,
|
||||
so the archived original is safe before any regenerate-on-read (ADR 0025)
|
||||
could overwrite the original name; because the original stays in place, a
|
||||
DB error or no-op afterwards leaves the canonical archive exactly as it
|
||||
was (with a harmless spare copy). Keeping the original also removes the
|
||||
half-state a copy-then-delete would have ("copied but couldn't delete").
|
||||
Best-effort: a copy failure is logged and counted
|
||||
(`fuchs.blobstorage.backupcopies.failed`), the DB backup row stays the
|
||||
authoritative copy, and the DB step still runs.
|
||||
- The action is exposed as a `Do_Process_Admin` case,
|
||||
`admin/backupclearinvoicefile/<invoiceId>`, gated by the existing
|
||||
`fds_sys > 4` check and restricted to `POST` (a GET must never trigger it,
|
||||
since GETs can be issued by crawlers/prefetchers without user intent). It
|
||||
is not part of any regular workflow UI; its only frontend is a card on the
|
||||
admin-only "Specialties" page (`/specialties`, see
|
||||
`Docs/Concepts/admin-specialties.md`).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Two invoice-file "backup and clear" code paths now exist for different
|
||||
audiences: `_BackupAndClearInvoiceFile` (manual, SSMS, no app log) and
|
||||
`fds__admin_backupAndClearInvoiceFile` (app-triggered, fully logged). Keep
|
||||
them separate — do not merge them, and do not point the manual proc's
|
||||
printed recovery links at the new admin action.
|
||||
- `fds__invoice_file_backups` accumulates one row per backup with no
|
||||
automatic retention/cleanup; if this ever becomes a storage concern, that
|
||||
is a separate decision.
|
||||
- Blob backup-copy is best-effort: if it fails (or storage is
|
||||
disabled/unconfigured), the invoice's DB row is still backed up and
|
||||
cleared — the archived blob is simply left as-is (and will be overwritten
|
||||
by the next regeneration). The DB backup table is the only *guaranteed*
|
||||
copy this action makes.
|
||||
- The blob container accumulates one `backup_<timestamp>_…` copy per action
|
||||
next to the current blob; like `fds__invoice_file_backups`, no retention
|
||||
is applied.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Call `_BackupAndClearInvoiceFile` directly from the app.** Rejected: its
|
||||
own header comment and ADR 0025 both state it must never be
|
||||
application-triggered, and it produces no application log entry — using it
|
||||
here would need updating that comment and (since decisions are immutable)
|
||||
superseding 0025's "Alternatives considered" note, for no real benefit over
|
||||
a small dedicated proc.
|
||||
- **Skip the blob rename and let the next regeneration overwrite the archived
|
||||
blob.** Rejected: the user asked for the blob copy to also survive as a
|
||||
backup, and a silent overwrite on next read would defeat that.
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-17
|
||||
applyTo:
|
||||
- "Fuchs/Services/IComService.cs"
|
||||
- "Fuchs/Services/ProcessWebComService.cs"
|
||||
- "Fuchs/Services/ProcessWebComSettings.cs"
|
||||
- "Fuchs/Services/MailerAttachmentPolicy.cs"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0028 — Mailer push_com: multipart attachments, count confirmation, contract pre-validation
|
||||
|
||||
## Context
|
||||
|
||||
The ProcessWeb Mailer API (`push_com`) was extended to accept attachments.
|
||||
Its contract — https://processweb.de/api/mailer/fc — specifies
|
||||
`multipart/form-data` with the JSON envelope in an unnamed form field and one
|
||||
file part per attachment, an optional `attachmentCount` safety check, and a
|
||||
200 response reporting `attachmentsReceived`/`attachmentsProcessed`.
|
||||
|
||||
`ProcessWebComService` predated that: it base64-inlined attachments as
|
||||
`communication.attachments[]` in a JSON body. That property is not part of
|
||||
the contract, so the Mailer accepted the message and dropped the files — a
|
||||
200 for an invoice mail whose PDF never arrived, with no error anywhere. The
|
||||
client also fabricated the audit `guid` (`Guid.NewGuid()`), treated a 409
|
||||
(idempotency duplicate) as a generic API error, and called the legacy
|
||||
`/mailer/fc?fn=verify` and `api.processweb.de` paths.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Request shape follows the contract exactly.** No attachments →
|
||||
`application/json` envelope. With attachments → `multipart/form-data`:
|
||||
envelope in an unnamed field (`name=""`, set via an explicit
|
||||
`Content-Disposition` because `MultipartFormDataContent.Add` rejects an
|
||||
empty name), then one file part per attachment with real bytes. Field
|
||||
names as in the contract (`comType`, not `comtype`). Base URL
|
||||
`https://processweb.de`, paths `/api/mailer/push_com` and
|
||||
`/api/mailer/verify`.
|
||||
2. **`attachmentCount` is always sent** so the Mailer itself rejects a
|
||||
request whose file parts don't match.
|
||||
3. **A 200 is not success when attachments were sent unless
|
||||
`attachmentsProcessed` equals the number sent.** Anything else — fewer
|
||||
processed, or a body that isn't the contract JSON — is reported to the
|
||||
caller as a failed send (`false`, reason `attachments-mismatch`, counts
|
||||
in the audit log). Callers gate "mark as sent" on that boolean, so an
|
||||
invoice whose PDF was not processed is never flagged as sent.
|
||||
4. **Contract limits are pre-validated client-side** (`MailerAttachmentPolicy`:
|
||||
count, per-file and per-message size, empty file, missing/forbidden
|
||||
extension) with the contract's defaults configurable under
|
||||
`Fuchs:Mailer:Attachments`. A violation fails before any bytes are sent,
|
||||
with the exact rule in the audit log. Empty files are a violation, not
|
||||
silently skipped as before.
|
||||
5. **`comStackID` is the audit key**, falling back to the `Message-Id`
|
||||
header; a local id only when the Mailer returned neither.
|
||||
6. **409 is a distinct failure** (`duplicate`): the Mailer already holds a
|
||||
message under this `reference` and did not queue this one.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `IComService.SendEmailAsync` returning `true` now means "accepted *and*
|
||||
every attachment confirmed processed" — strictly stronger than before.
|
||||
Existing callers (`req/sconf`, reminders, admin test mail) need no change;
|
||||
they already treat `false` as not-sent.
|
||||
- A caller passing a 0-byte attachment now gets `false` instead of a mail
|
||||
without the file. That is intended: an empty PDF is an upstream failure
|
||||
that should surface, not be hidden by a successful-looking send.
|
||||
- The forbidden-extension list in the client is the contract's quoted subset;
|
||||
the server's longer list stays authoritative and still answers 400 for the
|
||||
rest.
|
||||
- The concept doc `Docs/Concepts/processweb-mailer-interface.md` describes
|
||||
the current client behaviour and must be kept in sync when the contract
|
||||
evolves (it is declared backward compatible, so additions rather than
|
||||
breaks are expected).
|
||||
- The verify response is still parsed as `{ ok, account, serverid }`; if the
|
||||
`/api/mailer/verify` endpoint's body differs from the legacy `fc?fn=verify`
|
||||
one, only `VerifyCredentialsAsync`'s DTO needs adjusting.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep inline base64 and ask the Mailer to accept it too.** Rejected: the
|
||||
contract is the Mailer's; two accepted shapes would just be a second
|
||||
thing to keep compatible, and base64 inflates every PDF by a third.
|
||||
- **Treat 200 as success regardless of `attachmentsProcessed`.** Rejected —
|
||||
that is exactly the silent-drop failure mode this change exists to close.
|
||||
- **Treat 409 as success ("already sent").** Rejected: a resend under the
|
||||
same reference would then report success without anything being queued;
|
||||
the caller must see it and decide (e.g. use a new reference).
|
||||
- **Skip client-side validation and rely on the server's 400.** Rejected:
|
||||
the 400 body is an opaque string in the audit log; a named rule is what
|
||||
support needs, and it costs nothing.
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-09-17
|
||||
applyTo:
|
||||
- "Fuchs/Services/ProcessWebComService.cs"
|
||||
- "Fuchs/Services/FuchsMailerClient.cs"
|
||||
- "Fuchs/Services/MailerAttachmentPolicy.cs"
|
||||
- "Fuchs/Services/ProcessWebComSettings.cs"
|
||||
- "Fuchs/Program.cs"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0029 — Outbound mail goes through the OCORE_web PWMailer client
|
||||
|
||||
## Context
|
||||
|
||||
Right after [0028](0028-mailer-push-com-multipart-and-attachment-confirmation.md)
|
||||
re-implemented the Mailer API's `push_com` contract by hand in
|
||||
`ProcessWebComService`, OCORE_web gained a complete typed client for that
|
||||
API (`OCORE_web/comm/pwmailer/`, `IPWMailerClientService`): every operation,
|
||||
the JSON/multipart request shapes, `attachmentCount`, typed receipts,
|
||||
Basic auth with Key Vault/encrypted token resolution, `x-pcw-excd` error
|
||||
headers, retries and timeouts — declared to be kept in sync with the
|
||||
server-side controllers. Keeping a second implementation of the same
|
||||
contract in Fuchs would mean two things to update on every API change.
|
||||
|
||||
The client validated a message's required fields and empty/extension-less
|
||||
attachments, but had no hook for application rules (size limits, a
|
||||
file-type deny-list) — `PWMailerMessage` is sealed and `PushAsync` called
|
||||
`Validate` directly.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **`ProcessWebComService` is an adapter over `IPWMailerClientService`.**
|
||||
It keeps only what is Fuchs-specific: recipient validation and the
|
||||
`Fuchs:Email:OverrideRecipient` safety net, the signature, building the
|
||||
`PWMailerMessage`, interpreting the `PWMailerResult`, the `fds__emaillog`
|
||||
audit row and telemetry. All HTTP transport lives in the OCORE client;
|
||||
the hand-rolled `PostPushComAsync`/MIME guessing from 0028 is deleted.
|
||||
2. **Pre-validation is an overridable hook in the client.** OCORE_web's
|
||||
`PWMailerClientService` gains `protected virtual ValidateMessage(PWMailerMessage)`
|
||||
(default: `PWMailerMessage.Validate`), called by `PushAsync` and
|
||||
`PushBulkAsync` before anything is sent. Fuchs overrides it in
|
||||
`FuchsMailerClient` to add the contract's attachment limits
|
||||
(`MailerAttachmentPolicy`: count, per-file/per-message size, forbidden
|
||||
extensions, configurable under `Fuchs:Mailer:Attachments`). A refusal is a
|
||||
`PWMailerResult` with `StatusCode 0` / `Attempts 0`, which the adapter
|
||||
reports as reason `validation` with one audit line per rule.
|
||||
3. **One configuration section.** `Fuchs:Mailer` (`ProcessWebComSettings`)
|
||||
remains the source; `Program.cs` maps it onto `PWMailerClientOptions`
|
||||
(`BaseUrl + /api/mailer`, `AccountId → Account`, `Token`, `ServerId`) and
|
||||
sets `TokenSecretName = "Fuchs--Mailer--Token"`, i.e. the Key Vault secret
|
||||
OCORE secret management already maps into `Fuchs:Mailer:Token`. No second
|
||||
`PWMailer` section, no second secret.
|
||||
4. **0028's behavioural rules stand**, with one correction: multipart with
|
||||
`attachmentCount`, a 200 counts as success only when
|
||||
`attachmentsProcessed` equals the number sent, `comStackID` is the audit
|
||||
key. **409 is now a success, not a `duplicate` failure** (this replaces
|
||||
0028's rule 6): the client documents 409 as "duplicate of a still-pending
|
||||
entry with the same server/reference/content — nothing was enqueued, the
|
||||
earlier entry will be sent". The mail is therefore on its way; the resend
|
||||
paths (`req/resend`, reminders) reuse `inv_<Id>` as the reference, and a
|
||||
double click must not surface as a send error. The deduplication is
|
||||
recorded in the audit row. They are enforced partly by the client (shape,
|
||||
count) and partly by the adapter (processed check, audit, 409 mapping).
|
||||
5. Two small OCORE_web fixes made in the same change: `PWMailerMessage.Validate`
|
||||
no longer demands a subject for `comType "s"` (SMS), and a call refused
|
||||
by validation reports `Attempts = 0` as its documentation already stated.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Fuchs no longer asserts the wire format in its own tests; that lives in
|
||||
`OCORE_web/OCORE_webTests/comm/PWMailerClientServiceTests.cs`. Fuchs tests
|
||||
assert the `PWMailerMessage` handed to the client and the interpretation of
|
||||
each `PWMailerResult` shape, plus `FuchsMailerClient`'s refusals.
|
||||
- OCORE_web is a submodule owned by another repository: the hook, the SMS
|
||||
rule and the `Attempts` fix are a commit there and a pointer bump here.
|
||||
- The client is a singleton with a cached token; after a token rotation
|
||||
`IPWMailerClientService.ResetToken()` (on the concrete type) is the way to
|
||||
pick it up without a restart — not wired to any admin action yet.
|
||||
- Empty attachments are still refused (now by the client's base rule), so
|
||||
an upstream render failure never produces a mail without its PDF. The
|
||||
flip side: a caller attaching an *optional* file must skip an empty byte
|
||||
array itself — the reminder's invoice copy (`IntranetController.Reminder.cs`)
|
||||
does, otherwise a never-stored invoice file would fail the whole reminder
|
||||
instead of sending it without the copy.
|
||||
- The client's `getCom`/`getComStatus`/`abortCom` and inbox operations are
|
||||
available to Fuchs for free (e.g. a delivery-status column keyed by the
|
||||
audited `comStackID`) — not used yet.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep the 0028 hand-rolled transport.** Rejected: duplicate contract
|
||||
implementation, and the OCORE client is the one declared to track the
|
||||
server.
|
||||
- **Pre-validate in the adapter only (before `PushAsync`).** Rejected: the
|
||||
rules belong with the client's own validation so every OCORE consumer can
|
||||
apply them the same way, and the user asked for an overridable hook.
|
||||
- **Bind a separate `PWMailer` config section.** Rejected: `Fuchs:Mailer` is
|
||||
already Key Vault-managed and read by the Admin status view; mapping in
|
||||
code avoids a second token secret and drift between two sections.
|
||||
@@ -216,7 +216,9 @@ flowchart TD
|
||||
## 6. Sending the invoice by email
|
||||
|
||||
Email delivery is handled by `IComService` / `ProcessWebComService`, which
|
||||
talks to the **ProcessWeb Mailer API** (`push_com`, `comType=email`).
|
||||
talks to the **ProcessWeb Mailer API** (`POST /api/mailer/push_com`,
|
||||
`comType="e"`; contract: https://processweb.de/api/mailer/fc, client side
|
||||
described in `Docs/Concepts/processweb-mailer-interface.md`).
|
||||
|
||||
- `HandleRequestSconf` builds the HTML body via `BuildInvoiceBody(balance,
|
||||
paymentTerms)` — a fixed German thank-you text, the amount (if non-zero),
|
||||
@@ -234,8 +236,12 @@ talks to the **ProcessWeb Mailer API** (`push_com`, `comType=email`).
|
||||
- A signature is appended to the HTML body.
|
||||
- If the mailer is disabled (`_settings.Enabled == false`), sending is a
|
||||
documented no-op.
|
||||
- Attachments are base64-encoded inline (`{ filename, mimeType,
|
||||
contentBase64 }`) and POSTed to the Mailer API.
|
||||
- Attachments are pre-validated against the Mailer's limits (size, count,
|
||||
no empty file, extension rules) and POSTed as `multipart/form-data` —
|
||||
envelope JSON in an unnamed field plus one file part per attachment, with
|
||||
`attachmentCount` in the envelope.
|
||||
- The Mailer's 200 response must confirm `attachmentsProcessed` equal to
|
||||
the number sent; otherwise the send is reported as failed (ADR 0028).
|
||||
- Only on a **successful** send does the controller call
|
||||
`fds__setInvoiceSent @auto=true`, which sets `IsSent = 1` — so a failed
|
||||
mailer call leaves the invoice finalised but *not marked sent* (visible via
|
||||
@@ -252,8 +258,10 @@ sequenceDiagram
|
||||
C->>Com: SendEmailAsync("inv_<Id>", subject, body, email, "", {pdf})
|
||||
Com->>Com: validate email, apply OverrideRecipient (dev), append signature
|
||||
alt mailer enabled
|
||||
Com->>API: POST push_com {comType:"email", attachments:[base64 pdf]}
|
||||
API-->>Com: success/failure
|
||||
Com->>Com: MailerAttachmentPolicy.Validate(pdf)
|
||||
Com->>API: POST /api/mailer/push_com (multipart: envelope{comType:"e", attachmentCount:1} + pdf part)
|
||||
API-->>Com: 200 {comStackID, attachmentsProcessed} / 4xx/5xx
|
||||
Com->>Com: attachmentsProcessed == 1 ? success : failure
|
||||
else mailer disabled
|
||||
Com-->>Com: no-op (logged)
|
||||
end
|
||||
|
||||
@@ -51,6 +51,15 @@ public static class FuchsTelemetry
|
||||
Meter.CreateCounter<long>("fuchs.blobstorage.uploads", "{upload}", "Number of documents successfully archived to Azure Blob Storage.");
|
||||
public static readonly Counter<long> BlobUploadsFailed =
|
||||
Meter.CreateCounter<long>("fuchs.blobstorage.uploads.failed", "{upload}", "Number of documents that failed to archive to Azure Blob Storage.");
|
||||
public static readonly Counter<long> BlobBackupCopiesSucceeded =
|
||||
Meter.CreateCounter<long>("fuchs.blobstorage.backupcopies", "{copy}",
|
||||
"Number of archived blobs successfully copied to a backup name (admin invoice-file backup/clear).");
|
||||
public static readonly Counter<long> BlobBackupCopiesFailed =
|
||||
Meter.CreateCounter<long>("fuchs.blobstorage.backupcopies.failed", "{copy}",
|
||||
"Number of blob backup-copy attempts that failed.");
|
||||
public static readonly Counter<long> InvoiceFileBackups =
|
||||
Meter.CreateCounter<long>("fuchs.invoice.filebackups", "{backup}",
|
||||
"Admin-triggered invoice file backup+clear actions (Do_Process_Admin backupclearinvoicefile), tagged by outcome.");
|
||||
public static readonly Counter<long> SystemProbes =
|
||||
Meter.CreateCounter<long>("fuchs.systemstatus.probes", "{probe}",
|
||||
"Admin system-status connectivity probes executed, tagged by component and outcome.");
|
||||
|
||||
+18
-2
@@ -8,9 +8,11 @@ using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OCORE.MultiLogger;
|
||||
using OCORE.security;
|
||||
using OCORE.web;
|
||||
using OCORE.web.pwmailer;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
@@ -136,8 +138,22 @@ public class Program
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
// Communication service (email + SMS via ProcessWeb Mailer API)
|
||||
// Communication service (email + SMS via ProcessWeb Mailer API). Transport is the OCORE_web
|
||||
// PWMailer client (ADR 0029); its options are mapped from the existing Fuchs:Mailer section
|
||||
// rather than a second config section, so the Key Vault-managed Fuchs:Mailer:Token keeps working
|
||||
// (secret Fuchs--Mailer--Token also named as the on-demand fallback for the placeholder case).
|
||||
builder.Services.Configure<ProcessWebComSettings>(builder.Configuration.GetSection("Fuchs:Mailer"));
|
||||
builder.Services.AddOptions<PWMailerClientOptions>()
|
||||
.Configure<IOptions<ProcessWebComSettings>>((o, s) =>
|
||||
{
|
||||
o.BaseUrl = s.Value.BaseUrl.TrimEnd('/') + "/api/mailer";
|
||||
o.Account = s.Value.AccountId;
|
||||
o.Token = s.Value.Token;
|
||||
o.TokenSecretName = "Fuchs--Mailer--Token";
|
||||
o.ServerId = s.Value.ServerId;
|
||||
o.UserAgent = "Fuchs Intranet";
|
||||
});
|
||||
builder.Services.AddPWMailerClient<FuchsMailerClient>();
|
||||
// Dev/test safety net: Fuchs:Email:OverrideRecipient redirects every outbound email
|
||||
// (see appsettings.Development.json) so real tenant-owners/end-customers are never emailed.
|
||||
builder.Services.Configure<FuchsEmailSettings>(builder.Configuration.GetSection("Fuchs:Email"));
|
||||
@@ -148,7 +164,6 @@ public class Program
|
||||
builder.Services.AddSingleton<IERechnungValidator, ProcessWebERechnungValidator>(); // external EN16931 + PDF/A-3 check
|
||||
builder.Services.AddHttpClient(ProcessWebERechnungValidator.HttpClientName,
|
||||
c => c.Timeout = TimeSpan.FromSeconds(90));
|
||||
builder.Services.AddHttpClient("ProcessWebMailer");
|
||||
// Fetches invoice-editor images (mfr:// picture links) for inline PDF rendering; whitelist-
|
||||
// restricted to the mfr portal (see InvoiceService.AllowedInlineImagePrefix / ADR 0018).
|
||||
builder.Services.AddHttpClient(InvoiceService.InlineImageHttpClientName,
|
||||
@@ -166,6 +181,7 @@ public class Program
|
||||
builder.Services.AddScoped<IWidgetService, FuchsWidgetService>();
|
||||
builder.Services.AddScoped<IReportService, FuchsReportService>();
|
||||
builder.Services.AddScoped<IInvoiceService, InvoiceService>();
|
||||
builder.Services.AddScoped<IInvoiceFileBackupService, InvoiceFileBackupService>();
|
||||
builder.Services.AddScoped<IReminderService, ReminderService>();
|
||||
builder.Services.AddScoped<IEventService, EventService>();
|
||||
|
||||
|
||||
@@ -68,6 +68,60 @@ public class AzureBlobStorageService : IBlobStorageService
|
||||
byte[] content, IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default)
|
||||
=> UploadAsync(category, containerName, documentId, fileName, content, sourceRow, cancellationToken);
|
||||
|
||||
public Task<Uri?> CopyInvoicePdfToBackupAsync(string invoiceId, string fileName,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> CopyToBackupAsync("invoice", _settings.InvoiceContainer, invoiceId, fileName, cancellationToken);
|
||||
|
||||
private async Task<Uri?> CopyToBackupAsync(string category, string containerName, string documentId,
|
||||
string fileName, CancellationToken cancellationToken)
|
||||
{
|
||||
if (_client == null) return null;
|
||||
|
||||
string blobName = BuildBlobName(documentId, fileName);
|
||||
using var act = FuchsTelemetry.StartActivity("blobstorage.copytobackup");
|
||||
act?.SetTag("fuchs.blobstorage.category", category);
|
||||
act?.SetTag("fuchs.blobstorage.id", documentId);
|
||||
|
||||
try
|
||||
{
|
||||
var containerClient = _client.GetBlobContainerClient(containerName);
|
||||
var sourceBlob = containerClient.GetBlobClient(blobName);
|
||||
Response<bool> exists = await sourceBlob.ExistsAsync(cancellationToken);
|
||||
if (!exists.Value)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Blob backup-copy skipped for {Category} {Id} — no existing blob at {Blob}.",
|
||||
category, documentId, blobName);
|
||||
return null;
|
||||
}
|
||||
|
||||
string backupBlobFileName = string.IsNullOrWhiteSpace(fileName) ? $"{documentId}.pdf" : fileName;
|
||||
string backupName = $"{documentId}/backup_{DateTime.UtcNow:yyyyMMdd_HHmmss}_{backupBlobFileName}";
|
||||
var backupBlob = containerClient.GetBlobClient(backupName);
|
||||
// Server-side copy: the bytes never pass through this process. A private source in the
|
||||
// same storage account is authorised by the request's own Shared Key credential, so no
|
||||
// SAS is needed. WaitForCompletionAsync throws if the copy ends in a failed/aborted state.
|
||||
// The original is deliberately left in place — it stays the "current" archive until the
|
||||
// next regeneration overwrites it, and nothing can be half-renamed.
|
||||
CopyFromUriOperation copy = await backupBlob.StartCopyFromUriAsync(
|
||||
sourceBlob.Uri, (BlobCopyFromUriOptions?)null, cancellationToken);
|
||||
await copy.WaitForCompletionAsync(cancellationToken);
|
||||
|
||||
FuchsTelemetry.BlobBackupCopiesSucceeded.Add(1, new KeyValuePair<string, object?>("category", category));
|
||||
_logger.LogInformation("Copied {Category} {Id} blob {Old} to backup {New}.",
|
||||
category, documentId, blobName, backupName);
|
||||
return backupBlob.Uri;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
FuchsTelemetry.BlobBackupCopiesFailed.Add(1, new KeyValuePair<string, object?>("category", category));
|
||||
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
|
||||
_logger.LogError(ex, "Blob backup-copy failed for {Category} {Id} ({Blob}).",
|
||||
category, documentId, blobName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OCORE.web;
|
||||
using OCORE.web.pwmailer;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The OCORE_web <see cref="PWMailerClientService"/> with Fuchs' pre-validation: on top of the
|
||||
/// contract rules the base applies, every outbound message is checked against the Mailer's
|
||||
/// attachment limits (<see cref="MailerAttachmentPolicy"/>, configured under
|
||||
/// <c>Fuchs:Mailer:Attachments</c>) before any bytes are sent. Registered as the application's
|
||||
/// <see cref="IPWMailerClientService"/> in <c>Program.cs</c>; options are mapped from
|
||||
/// <c>Fuchs:Mailer</c> (<see cref="ProcessWebComSettings"/>) there.
|
||||
/// </summary>
|
||||
public class FuchsMailerClient : PWMailerClientService
|
||||
{
|
||||
private readonly MailerAttachmentLimits _limits;
|
||||
|
||||
public FuchsMailerClient(IOptions<PWMailerClientOptions> options, IOptions<ProcessWebComSettings> settings,
|
||||
IHttpClientFactory httpClientFactory, ILogger<PWMailerClientService> logger,
|
||||
IOCORE_web_securityservice? securityService = null)
|
||||
: base(options, httpClientFactory, logger, securityService)
|
||||
{
|
||||
_limits = settings.Value.Attachments;
|
||||
}
|
||||
|
||||
protected override IReadOnlyList<string> ValidateMessage(PWMailerMessage message)
|
||||
{
|
||||
var problems = new List<string>(base.ValidateMessage(message));
|
||||
problems.AddRange(MailerAttachmentPolicy.Validate(message.Attachments, _limits));
|
||||
return problems;
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,16 @@ public interface IBlobStorageService
|
||||
Task<Uri?> UploadDocumentAsync(string category, string containerName, string documentId, string fileName,
|
||||
byte[] content, IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Server-side copies an existing invoice PDF blob to a timestamped backup name in the same
|
||||
/// container; the original blob is left in place. Called before an invoice's stored file is
|
||||
/// cleared: the next regenerate-on-read (ADR 0025) uploads under the original blob name with
|
||||
/// <c>overwrite: true</c>, and the backup copy is what survives that. Returns the backup
|
||||
/// blob's URI, or <c>null</c> when there was nothing to back up, storage is disabled/unconfigured,
|
||||
/// or the copy failed — logged, never throws.
|
||||
/// </summary>
|
||||
Task<Uri?> CopyInvoicePdfToBackupAsync(string invoiceId, string fileName, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>true</c> if a blob already exists for the given container/document/filename
|
||||
/// combination. Used by the startup backfill to skip documents that were already archived.
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction for outbound communication: email and SMS.
|
||||
/// Backed by the ProcessWeb Mailer API (POST {BaseUrl}/mailer/push_com).
|
||||
/// Backed by the ProcessWeb Mailer API (POST {BaseUrl}/api/mailer/push_com); contract at
|
||||
/// https://processweb.de/api/mailer/fc, client-side description in
|
||||
/// Docs/Concepts/processweb-mailer-interface.md.
|
||||
/// </summary>
|
||||
public interface IComService
|
||||
{
|
||||
@@ -14,9 +16,15 @@ public interface IComService
|
||||
/// <param name="html">HTML body content (signature is appended automatically).</param>
|
||||
/// <param name="email">Recipient email address.</param>
|
||||
/// <param name="name">Recipient display name.</param>
|
||||
/// <param name="attachments">Optional file attachments (filename → content).</param>
|
||||
/// <param name="attachments">
|
||||
/// Optional file attachments (filename → content). Validated against the Mailer's limits
|
||||
/// (<see cref="MailerAttachmentPolicy"/>) before sending; any violation fails the send.
|
||||
/// </param>
|
||||
/// <param name="cc">Optional Cc recipient (e.g. a customer's "Zusatzempfänger").</param>
|
||||
/// <returns><c>true</c> when the email was accepted successfully.</returns>
|
||||
/// <returns>
|
||||
/// <c>true</c> only when the Mailer accepted the message <em>and</em> confirmed that every
|
||||
/// attachment was processed (<c>attachmentsProcessed</c> equals the number sent).
|
||||
/// </returns>
|
||||
Task<bool> SendEmailAsync(string reference, string subject, string html,
|
||||
string email, string name, Dictionary<string, byte[]>? attachments = null, string? cc = null);
|
||||
|
||||
@@ -30,7 +38,7 @@ public interface IComService
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the configured account/token against the Mailer API's credential-check
|
||||
/// endpoint (GET {BaseUrl}/mailer/fc?fn=verify) without sending anything.
|
||||
/// endpoint (GET {BaseUrl}/api/mailer/verify) without sending anything.
|
||||
/// </summary>
|
||||
Task<MailerCredentialsCheckResult> VerifyCredentialsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using OCORE.security;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Admin-only: backs up an invoice's stored PDF and clears it so the document regenerates on the
|
||||
/// next read (ADR 0025). See Docs/Decisions/0027-admin-invoice-file-backup-and-clear.md for why
|
||||
/// this is a dedicated service/proc rather than reusing the manual-only
|
||||
/// <c>_BackupAndClearInvoiceFile</c> maintenance tool.
|
||||
/// </summary>
|
||||
public interface IInvoiceFileBackupService
|
||||
{
|
||||
/// <summary>
|
||||
/// Backs up the invoice's current stored file (DB row via
|
||||
/// <c>fds__admin_backupAndClearInvoiceFile</c>, plus a best-effort rename-to-backup of the
|
||||
/// archived blob) and clears the stored file.
|
||||
/// </summary>
|
||||
Task<InvoiceFileBackupResult> BackupAndClearAsync(string id, string userAccountId, DatabaseSecurity dbSec,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>Outcome of <see cref="IInvoiceFileBackupService.BackupAndClearAsync"/>.</summary>
|
||||
public sealed class InvoiceFileBackupResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
|
||||
/// <summary>Machine-readable reason when <see cref="Success"/> is <c>false</c>.</summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>The archived blob's backup URI, when the blob rename succeeded (best-effort — can be <c>null</c> even on success).</summary>
|
||||
public Uri? BlobBackupUri { get; init; }
|
||||
}
|
||||
@@ -22,7 +22,11 @@ public interface ISystemStatusService
|
||||
/// <summary>
|
||||
/// Sends a test email through the normal <see cref="IComService"/> pipeline, so the
|
||||
/// dev/test recipient override (<see cref="FuchsEmailSettings.OverrideRecipient"/>) applies
|
||||
/// exactly as for any other outbound email.
|
||||
/// exactly as for any other outbound email. With <paramref name="attachPdf"/> (the default)
|
||||
/// a small letter-layout PDF rendered from the test mail's subject/body is attached, so the
|
||||
/// Mailer's attachment path (multipart + <c>attachmentsProcessed</c> confirmation, ADR 0028)
|
||||
/// is exercised too; <c>false</c> sends a plain text-only mail.
|
||||
/// </summary>
|
||||
Task<EmailTestResult> SendTestEmailAsync(string to, string subject, string body, CancellationToken cancellationToken = default);
|
||||
Task<EmailTestResult> SendTestEmailAsync(string to, string subject, string body,
|
||||
bool attachPdf = true, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OCORE.security;
|
||||
using OCORE.SQL;
|
||||
using static OCORE.commons;
|
||||
using static OCORE.SQL.sql;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>See <see cref="IInvoiceFileBackupService"/>.</summary>
|
||||
public class InvoiceFileBackupService : IInvoiceFileBackupService
|
||||
{
|
||||
private static readonly EventId BackupClearEvent = new(0, "AdminBackupClearInvoiceFile");
|
||||
|
||||
private readonly Fuchs_intranet _intranet;
|
||||
private readonly IInvoiceService _invoices;
|
||||
private readonly IBlobStorageService _blobStorage;
|
||||
private readonly ILogger<InvoiceFileBackupService> _logger;
|
||||
|
||||
public InvoiceFileBackupService(Fuchs_intranet intranet, IInvoiceService invoices,
|
||||
IBlobStorageService blobStorage, ILogger<InvoiceFileBackupService> logger)
|
||||
{
|
||||
_intranet = intranet;
|
||||
_invoices = invoices;
|
||||
_blobStorage = blobStorage;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private string Conn => _intranet.Intranet__SQLConnectionString;
|
||||
|
||||
public async Task<InvoiceFileBackupResult> BackupAndClearAsync(string id, string userAccountId,
|
||||
DatabaseSecurity dbSec, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var invoice = await _invoices.LoadInvoiceAsync(id, userAccountId, dbSec);
|
||||
// LoadInvoiceAsync always yields a registration (an empty dictionary for an unknown id),
|
||||
// so "not found" is an empty Id, never a null registration.
|
||||
if (invoice.InvoiceRegistration == null || string.IsNullOrEmpty(invoice.Id))
|
||||
{
|
||||
_logger.LogWarning(BackupClearEvent,
|
||||
"BackupAndClearAsync: invoice {Id} not found, user={User}", id, userAccountId);
|
||||
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", "invoice_not_found"));
|
||||
return new InvoiceFileBackupResult { Success = false, Reason = "invoice_not_found" };
|
||||
}
|
||||
|
||||
// Same precondition as regenerate-on-read (ADR 0025): only a finalised, non-external
|
||||
// invoice may have its file cleared. Clearing a draft's file would let the next read
|
||||
// persist a draft render through fds__setInvoiceFile (WHERE [file] IS NULL), which then
|
||||
// permanently blocks the real final PDF; an external (MFR) document has no template to
|
||||
// regenerate from, so clearing it would just leave a permanent 404.
|
||||
bool isExternal = invoice.InvoiceRegistration.getItem("IsExternal", false) is true;
|
||||
if (invoice.IsDraft || isExternal)
|
||||
{
|
||||
string reason = invoice.IsDraft ? "not_final" : "external";
|
||||
_logger.LogWarning(BackupClearEvent,
|
||||
"BackupAndClearAsync: refused for {Id} ({Reason}) — only finalised, non-external invoices can be cleared, user={User}",
|
||||
invoice.Id, reason, userAccountId);
|
||||
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", reason));
|
||||
return new InvoiceFileBackupResult { Success = false, Reason = reason };
|
||||
}
|
||||
|
||||
// fds__getInvoice reports hasFile; without a stored file there is nothing to back up or
|
||||
// clear, so stop here — before the blob copy — rather than leaving a spare backup_* blob
|
||||
// behind on every repeated click on an already-cleared invoice. The proc re-checks
|
||||
// [file] IS NOT NULL itself (OUTPUT row), so a race between this read and the clear is
|
||||
// still reported as no_file, just with one extra blob copy.
|
||||
if (invoice.InvoiceRegistration.getItem("hasFile", false) is not true)
|
||||
{
|
||||
_logger.LogWarning(BackupClearEvent,
|
||||
"BackupAndClearAsync: no file stored for {Id} — nothing to back up or clear, user={User}",
|
||||
invoice.Id, userAccountId);
|
||||
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", "no_file"));
|
||||
return new InvoiceFileBackupResult { Success = false, Reason = "no_file" };
|
||||
}
|
||||
|
||||
string fileName = invoice.InvoiceRegistration.getString("DocumentName").ne($"Rechnung_{invoice.Id}.pdf");
|
||||
|
||||
// Copy (not move) the archived blob to a backup name BEFORE the DB clear and leave the
|
||||
// original in place: the backup therefore exists before any regenerate-on-read (ADR 0025)
|
||||
// could overwrite the original name, and a DB failure/no-op below leaves the canonical
|
||||
// archive blob untouched (only a spare copy remains — harmless). Best-effort: a failure
|
||||
// here is logged, the DB backup row remains the authoritative copy.
|
||||
Uri? blobBackupUri;
|
||||
try
|
||||
{
|
||||
blobBackupUri = await _blobStorage.CopyInvoicePdfToBackupAsync(invoice.Id, fileName, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(BackupClearEvent, ex,
|
||||
"BackupAndClearAsync: blob backup-copy threw for {Id}, user={User}", invoice.Id, userAccountId);
|
||||
blobBackupUri = null;
|
||||
}
|
||||
|
||||
var pl = new List<SqlParameter>
|
||||
{
|
||||
SQL_VarChar("@authuser", userAccountId),
|
||||
SQL_VarChar("@Id", invoice.Id),
|
||||
};
|
||||
// fds__admin_backupAndClearInvoiceFile writes WHERE [file] IS NOT NULL and returns the
|
||||
// cleared Id via its OUTPUT clause, so an empty result set means nothing was backed
|
||||
// up/cleared (invoice missing, or file already empty) — rows-affected can't be used for
|
||||
// this because the proc runs SET NOCOUNT ON (same reasoning as fds__setInvoiceFile, ADR 0025).
|
||||
var cleared = await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fds__admin_backupAndClearInvoiceFile] @authuser, @Id;",
|
||||
Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
|
||||
if (!string.IsNullOrEmpty(cleared.Exception))
|
||||
{
|
||||
_logger.LogError(BackupClearEvent,
|
||||
"BackupAndClearAsync: fds__admin_backupAndClearInvoiceFile failed for {Id}: {Error}, user={User}",
|
||||
invoice.Id, cleared.Exception, userAccountId);
|
||||
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", "db_error"));
|
||||
return new InvoiceFileBackupResult { Success = false, Reason = "db_error", BlobBackupUri = blobBackupUri };
|
||||
}
|
||||
if (cleared.Count == 0)
|
||||
{
|
||||
_logger.LogWarning(BackupClearEvent,
|
||||
"BackupAndClearAsync: no file to back up/clear for {Id} (already empty or invoice missing), user={User}",
|
||||
invoice.Id, userAccountId);
|
||||
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", "no_file"));
|
||||
return new InvoiceFileBackupResult { Success = false, Reason = "no_file", BlobBackupUri = blobBackupUri };
|
||||
}
|
||||
|
||||
_logger.LogWarning(BackupClearEvent,
|
||||
"Admin backed up and cleared invoice file for {Id}, blobBackup={BlobBackupUri}, user={User}",
|
||||
invoice.Id, blobBackupUri, userAccountId);
|
||||
FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair<string, object?>("outcome", "cleared"));
|
||||
return new InvoiceFileBackupResult { Success = true, BlobBackupUri = blobBackupUri };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using OCORE.web.pwmailer;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Fuchs-side pre-validation of email attachments against the ProcessWeb Mailer API contract
|
||||
/// (https://processweb.de/api/mailer/fc, "Attachment Rules"): file count, per-file and per-message
|
||||
/// size, and the server's forbidden-extension list. The rules the server enforces unconditionally
|
||||
/// (empty file, missing extension, required fields) are already covered by
|
||||
/// <see cref="PWMailerMessage.Validate"/>; this adds the limit rules on top — see
|
||||
/// <see cref="FuchsMailerClient.ValidateMessage"/>. Pure — no I/O — so it is unit-testable.
|
||||
/// </summary>
|
||||
public static class MailerAttachmentPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions the Mailer rejects (case-insensitive). The contract lists these explicitly and
|
||||
/// states there are further ones; the server remains authoritative for anything not listed.
|
||||
/// </summary>
|
||||
internal static readonly HashSet<string> ForbiddenExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"ade", "adp", "app", "application", "appx", "aspx", "bas", "bat", "cab", "cer", "chm", "cmd",
|
||||
"com", "cpl", "crt", "csh", "dll", "drv", "exe", "fxp", "jar", "js", "jnlp", "msi", "ps1",
|
||||
"psc1", "psd1", "py", "pyc", "reg", "scf", "scr", "sys", "vbs", "wsf", "xll",
|
||||
};
|
||||
|
||||
/// <summary>Returns every limit rule the attachments violate, in a stable order; empty when acceptable.</summary>
|
||||
public static IReadOnlyList<string> Validate(IReadOnlyList<PWMailerAttachment> attachments, MailerAttachmentLimits limits)
|
||||
{
|
||||
var violations = new List<string>();
|
||||
if (attachments.Count == 0) return violations;
|
||||
|
||||
if (attachments.Count > limits.MaxCount)
|
||||
violations.Add($"Too many attachments: {attachments.Count} (max {limits.MaxCount})");
|
||||
|
||||
long total = 0;
|
||||
foreach (var a in attachments)
|
||||
{
|
||||
if (a == null) continue;
|
||||
long size = a.Bytes?.LongLength ?? 0;
|
||||
total += size;
|
||||
if (size > limits.MaxFileBytes)
|
||||
violations.Add($"Attachment '{a.FileName}' is {size:N0} bytes (max {limits.MaxFileBytes:N0})");
|
||||
|
||||
string ext = Path.GetExtension(Path.GetFileName(a.FileName ?? "")).TrimStart('.');
|
||||
if (ext.Length > 0 && ForbiddenExtensions.Contains(ext))
|
||||
violations.Add($"Attachment '{a.FileName}' has a forbidden extension (.{ext.ToLowerInvariant()})");
|
||||
}
|
||||
|
||||
if (total > limits.MaxMessageBytes)
|
||||
violations.Add($"Attachments total {total:N0} bytes (max {limits.MaxMessageBytes:N0} per message)");
|
||||
|
||||
return violations;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Data.SqlClient;
|
||||
@@ -8,13 +6,17 @@ using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json;
|
||||
using OCORE.SQL;
|
||||
using OCORE.web.pwmailer;
|
||||
using static OCORE.SQL.sql;
|
||||
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Outbound communication service backed by the ProcessWeb Mailer API
|
||||
/// (POST https://api.processweb.de/mailer/push_com).
|
||||
/// Outbound communication service: the Fuchs-specific parts of sending (recipient override safety
|
||||
/// net, signature, attachment limits, <c>fds__emaillog</c> audit, telemetry) on top of the OCORE_web
|
||||
/// <see cref="IPWMailerClientService"/>, which owns the ProcessWeb Mailer API transport (request
|
||||
/// shapes, auth, error headers, retries). Contract: https://processweb.de/api/mailer/fc — see
|
||||
/// Docs/Concepts/processweb-mailer-interface.md and ADRs 0028/0029.
|
||||
/// When <c>ProcessWebComSettings.Enabled</c> is <c>false</c> the service
|
||||
/// only logs the intended communication without calling the API.
|
||||
/// </summary>
|
||||
@@ -24,7 +26,7 @@ public class ProcessWebComService : IComService
|
||||
private readonly Fuchs_intranet _intranet;
|
||||
private readonly ProcessWebComSettings _settings;
|
||||
private readonly FuchsEmailSettings _emailSettings;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IPWMailerClientService _mailer;
|
||||
|
||||
private const string SignatureIntro =
|
||||
"<p> </p><p style=\"margin:24px 0 16px 0;line-height:140%;\">" +
|
||||
@@ -36,13 +38,13 @@ public class ProcessWebComService : IComService
|
||||
Fuchs_intranet intranet,
|
||||
IOptions<ProcessWebComSettings> settings,
|
||||
IOptions<FuchsEmailSettings> emailSettings,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
IPWMailerClientService mailer)
|
||||
{
|
||||
_logger = logger;
|
||||
_intranet = intranet;
|
||||
_settings = settings.Value;
|
||||
_emailSettings = emailSettings.Value;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_mailer = mailer;
|
||||
}
|
||||
|
||||
public async Task<bool> SendEmailAsync(string reference, string subject, string html,
|
||||
@@ -94,47 +96,83 @@ public class ProcessWebComService : IComService
|
||||
|
||||
bool success = false;
|
||||
string messageId = "";
|
||||
string failureReason = "api-error";
|
||||
var errors = new List<string>();
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Attachments are transmitted inline as base64 in the same push_com POST.
|
||||
// Each entry: { filename, mimeType, contentBase64 }.
|
||||
var attachmentPayload = (attachments ?? new Dictionary<string, byte[]>())
|
||||
.Where(kv => kv.Value is { Length: > 0 })
|
||||
.Select(kv => new
|
||||
{
|
||||
filename = kv.Key,
|
||||
mimeType = GuessMimeType(kv.Key),
|
||||
contentBase64 = Convert.ToBase64String(kv.Value)
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var communication = new
|
||||
var message = new PWMailerMessage
|
||||
{
|
||||
to = email,
|
||||
cc,
|
||||
subject,
|
||||
body,
|
||||
attachments = attachmentPayload
|
||||
To = email, Cc = cc, Subject = subject, Body = body, Reference = reference, ComType = "e"
|
||||
};
|
||||
foreach (var (fileName, bytes) in attachments ?? new Dictionary<string, byte[]>())
|
||||
message.Attachments.Add(new PWMailerAttachment(fileName, bytes));
|
||||
|
||||
_logger.LogDebug("SendEmailAsync ref={Reference} to={Email} cc={Cc} attachments={Count}",
|
||||
reference, email, cc ?? "", attachmentPayload.Length);
|
||||
reference, email, cc ?? "", message.Attachments.Count);
|
||||
|
||||
var (ok, status, responseBody) = await PostToApiAsync(reference, "e", communication);
|
||||
if (ok)
|
||||
// PushAsync never throws for HTTP/transport/validation problems; the result says what happened.
|
||||
// Pre-validation (contract rules + Fuchs attachment limits, see FuchsMailerClient) runs inside
|
||||
// it and is reported as a refused call: StatusCode 0 and no attempt made.
|
||||
var result = await _mailer.PushAsync(message);
|
||||
if (result.Success)
|
||||
{
|
||||
success = true;
|
||||
messageId = Guid.NewGuid().ToString("N");
|
||||
var receipt = result.Value!;
|
||||
if (message.Attachments.Count > 0 && receipt.AttachmentsProcessed != message.Attachments.Count)
|
||||
{
|
||||
// A 200 alone does not prove the files made it: the contract reports how many
|
||||
// attachments were received and actually processed. Anything but an exact match
|
||||
// is a failed send from the caller's point of view.
|
||||
failureReason = "attachments-mismatch";
|
||||
errors.Add($"Mailer accepted the message but not all {message.Attachments.Count} attachment(s): " +
|
||||
$"attachmentsReceived={receipt.AttachmentsReceived}, attachmentsProcessed={receipt.AttachmentsProcessed}");
|
||||
_logger.LogError("SendEmailAsync: attachment count mismatch for {Reference}: sent={Sent} received={Received} processed={Processed} comStackID={ComStackId}",
|
||||
reference, message.Attachments.Count, receipt.AttachmentsReceived, receipt.AttachmentsProcessed, receipt.ComStackID);
|
||||
}
|
||||
else
|
||||
{
|
||||
success = true;
|
||||
}
|
||||
// comStackID is what getComStatus/getCom accept, so it is the audit key either way.
|
||||
messageId = receipt.ComStackID;
|
||||
}
|
||||
else if (result.IsConflict)
|
||||
{
|
||||
// Idempotency guard: an identical message (same server/reference/content) is still
|
||||
// pending at the Mailer and WILL be delivered; this one was not queued a second time.
|
||||
// From the caller's point of view the mail is on its way — a resend clicked twice
|
||||
// must not surface as a send error — so this is a success with a note in the audit log.
|
||||
success = true;
|
||||
errors.Add($"Duplicate of a still-pending entry (409) — the earlier entry will be sent, nothing new was queued: {result.Error}");
|
||||
_logger.LogInformation("SendEmailAsync: duplicate of pending entry for {Reference}, earlier entry will be sent: {Error}",
|
||||
reference, result.Error);
|
||||
}
|
||||
else if (result.Attempts == 0 && result.StatusCode == 0 && result.Exception is ArgumentException)
|
||||
{
|
||||
// Refused by message/attachment pre-validation (PWMailerClientService.ValidateMessage,
|
||||
// incl. FuchsMailerClient's limits): the client wraps the rule messages in an
|
||||
// ArgumentException. A caller problem — one audit line per rule.
|
||||
failureReason = "validation";
|
||||
errors.AddRange(result.Error.Split("; ", StringSplitOptions.RemoveEmptyEntries));
|
||||
_logger.LogWarning("SendEmailAsync: refused by pre-validation for {Reference}: {Error}", reference, result.Error);
|
||||
}
|
||||
else if (result.Attempts == 0 && result.StatusCode == 0)
|
||||
{
|
||||
// Refused before sending for an operational reason: unusable PWMailer options or a
|
||||
// token that could not be resolved (Key Vault outage, missing secret). Nothing the
|
||||
// caller did — an Error, and its own metric reason, so it is never mistaken for a
|
||||
// bad attachment.
|
||||
failureReason = "client-config";
|
||||
errors.Add($"Mailer client not usable: {result.Error}");
|
||||
_logger.LogError("SendEmailAsync: mailer client not usable for {Reference}: {Error}", reference, result.Error);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Persist the mailer's raw response so an unsuccessful send is diagnosable from the log.
|
||||
errors.Add($"API error {(int)status} ({status}): {responseBody}");
|
||||
_logger.LogWarning("SendEmailAsync API error for {Reference}: {Status} {Body}",
|
||||
reference, (int)status, responseBody);
|
||||
// Persist the mailer's error so an unsuccessful send is diagnosable from the log.
|
||||
errors.Add($"API error {result.StatusCode}: {result.Error}");
|
||||
_logger.LogWarning("SendEmailAsync API error for {Reference}: {Status} {Error}",
|
||||
reference, result.StatusCode, result.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -150,7 +188,7 @@ public class ProcessWebComService : IComService
|
||||
FuchsTelemetry.EmailSendDuration.Record(sw.Elapsed.TotalMilliseconds,
|
||||
new KeyValuePair<string, object?>("success", success));
|
||||
if (success) FuchsTelemetry.EmailsSent.Add(1);
|
||||
else FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "api-error"));
|
||||
else FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", failureReason));
|
||||
_logger.LogInformation("SendEmailAsync ref={Reference} success={Success} in {Ms} ms",
|
||||
reference, success, sw.ElapsedMilliseconds);
|
||||
|
||||
@@ -177,21 +215,18 @@ public class ProcessWebComService : IComService
|
||||
|
||||
try
|
||||
{
|
||||
var communication = new
|
||||
var result = await _mailer.PushAsync(new PWMailerMessage
|
||||
{
|
||||
to = mobile,
|
||||
body = message
|
||||
};
|
||||
|
||||
var (ok, status, responseBody) = await PostToApiAsync(mobile, "s", communication);
|
||||
if (ok)
|
||||
To = mobile, Body = message, Reference = mobile, ComType = "s"
|
||||
});
|
||||
if (result.Success)
|
||||
{
|
||||
FuchsTelemetry.SmsSent.Add(1);
|
||||
_logger.LogInformation("SendSmsAsync sent to {Mobile}", mobile);
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogWarning("SendSmsAsync API error for {Mobile}: {Status} {Body}", mobile, (int)status, responseBody);
|
||||
_logger.LogWarning("SendSmsAsync API error for {Mobile}: {Status} {Error}", mobile, result.StatusCode, result.Error);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -206,85 +241,33 @@ public class ProcessWebComService : IComService
|
||||
if (string.IsNullOrWhiteSpace(_settings.AccountId) || string.IsNullOrWhiteSpace(_settings.Token))
|
||||
return new MailerCredentialsCheckResult { Ok = false, Detail = "Konto-ID oder Token nicht konfiguriert." };
|
||||
|
||||
var client = _httpClientFactory.CreateClient("ProcessWebMailer");
|
||||
string credentials = Convert.ToBase64String(
|
||||
Encoding.UTF8.GetBytes($"{_settings.AccountId}:{_settings.Token}"));
|
||||
|
||||
// Passing serverid makes the Mailer API also confirm that THIS server is one of the
|
||||
// account's usable servers, not just that the account/token pair is valid.
|
||||
string url = $"{_settings.BaseUrl}/mailer/fc?fn=verify";
|
||||
if (!string.IsNullOrWhiteSpace(_settings.ServerId))
|
||||
url += $"&serverid={Uri.EscapeDataString(_settings.ServerId)}";
|
||||
string? serverId = string.IsNullOrWhiteSpace(_settings.ServerId) ? null : _settings.ServerId;
|
||||
var result = await _mailer.VerifyAsync(serverId, cancellationToken);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials);
|
||||
|
||||
try
|
||||
if (!result.Success)
|
||||
{
|
||||
using var response = await client.SendAsync(request, cancellationToken);
|
||||
string body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return new MailerCredentialsCheckResult
|
||||
{
|
||||
Ok = false,
|
||||
Detail = $"API-Fehler {(int)response.StatusCode} ({response.StatusCode}): {body}",
|
||||
};
|
||||
|
||||
var payload = JsonConvert.DeserializeObject<MailerVerifyResponse>(body);
|
||||
bool ok = payload?.Ok == true;
|
||||
string? detail = ok ? null
|
||||
: (!string.IsNullOrWhiteSpace(payload?.ServerId)
|
||||
? $"Server-ID '{payload!.ServerId}' gehört nicht zu diesem Mailer-Konto."
|
||||
: "Mailer API meldete ungültige Zugangsdaten.");
|
||||
if (result.IsTransportError) _logger.LogWarning("VerifyCredentialsAsync failed: {Error}", result.Error);
|
||||
return new MailerCredentialsCheckResult
|
||||
{
|
||||
Ok = ok,
|
||||
Account = payload?.Account,
|
||||
ServerId = payload?.ServerId,
|
||||
Detail = detail,
|
||||
Ok = false,
|
||||
Detail = result.IsTransportError ? result.Error : $"API-Fehler {result.StatusCode}: {result.Error}",
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
var payload = result.Value!;
|
||||
string? detail = payload.Ok ? null
|
||||
: (!string.IsNullOrWhiteSpace(payload.ServerId)
|
||||
? $"Server-ID '{payload.ServerId}' gehört nicht zu diesem Mailer-Konto."
|
||||
: "Mailer API meldete ungültige Zugangsdaten.");
|
||||
return new MailerCredentialsCheckResult
|
||||
{
|
||||
_logger.LogWarning(ex, "VerifyCredentialsAsync failed");
|
||||
return new MailerCredentialsCheckResult { Ok = false, Detail = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class MailerVerifyResponse
|
||||
{
|
||||
public bool Ok { get; set; }
|
||||
public string? Account { get; set; }
|
||||
public string? ServerId { get; set; }
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<(bool ok, System.Net.HttpStatusCode status, string body)> PostToApiAsync(string reference, string comtype, object communication)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("ProcessWebMailer");
|
||||
|
||||
// The Mailer API expects a fixed envelope: { serverid, comtype, communication, reference }.
|
||||
// "communication" carries the actual channel-specific payload (to/subject/body/attachments).
|
||||
var payload = new
|
||||
{
|
||||
serverid = _settings.ServerId,
|
||||
comtype,
|
||||
communication,
|
||||
reference
|
||||
Ok = payload.Ok,
|
||||
Account = string.IsNullOrEmpty(payload.Account) ? null : payload.Account,
|
||||
ServerId = payload.ServerId,
|
||||
Detail = detail,
|
||||
};
|
||||
var json = JsonConvert.SerializeObject(payload);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
string credentials = Convert.ToBase64String(
|
||||
Encoding.UTF8.GetBytes($"{_settings.AccountId}:{_settings.Token}"));
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Basic", credentials);
|
||||
|
||||
var response = await client.PostAsync($"{_settings.BaseUrl}/mailer/push_com", content);
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
return (response.IsSuccessStatusCode, response.StatusCode, responseBody);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -375,24 +358,6 @@ public class ProcessWebComService : IComService
|
||||
return "";
|
||||
}
|
||||
|
||||
private static string GuessMimeType(string filename) =>
|
||||
Path.GetExtension(filename).ToLowerInvariant() switch
|
||||
{
|
||||
".pdf" => "application/pdf",
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".gif" => "image/gif",
|
||||
".txt" => "text/plain",
|
||||
".csv" => "text/csv",
|
||||
".xml" => "application/xml",
|
||||
".zip" => "application/zip",
|
||||
".doc" => "application/msword",
|
||||
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls" => "application/vnd.ms-excel",
|
||||
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
|
||||
private static bool IsValidEmail(string email)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
namespace Fuchs.Services;
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Settings for the ProcessWeb Mailer API, bound from appsettings.json → "Fuchs:Mailer".
|
||||
/// Contract: https://processweb.de/api/mailer/fc (see Docs/Concepts/processweb-mailer-interface.md).
|
||||
/// </summary>
|
||||
public class ProcessWebComSettings
|
||||
{
|
||||
/// <summary>Base URL of the ProcessWeb API (e.g. "https://api.processweb.de").</summary>
|
||||
public string BaseUrl { get; set; } = "https://api.processweb.de";
|
||||
/// <summary>Base URL of the ProcessWeb host (e.g. "https://processweb.de"); endpoints live under <c>/api/mailer/…</c>.</summary>
|
||||
public string BaseUrl { get; set; } = "https://processweb.de";
|
||||
|
||||
/// <summary>Account ID used for HTTP Basic authentication.</summary>
|
||||
public string AccountId { get; set; } = "";
|
||||
@@ -23,4 +24,24 @@ public class ProcessWebComSettings
|
||||
/// Set to <c>true</c> to enable live sending.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Client-side mirror of the Mailer API's attachment limits, checked before a push_com call
|
||||
/// so a violation is reported with a precise reason instead of an opaque 400. Defaults are
|
||||
/// the contract's documented defaults; the server stays authoritative.
|
||||
/// </summary>
|
||||
public MailerAttachmentLimits Attachments { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>See <see cref="ProcessWebComSettings.Attachments"/>.</summary>
|
||||
public sealed class MailerAttachmentLimits
|
||||
{
|
||||
/// <summary>Maximum size of a single attachment in bytes (contract default: 10 MB).</summary>
|
||||
public long MaxFileBytes { get; set; } = 10L * 1024 * 1024;
|
||||
|
||||
/// <summary>Maximum total attachment size per message in bytes (contract default: 20 MB).</summary>
|
||||
public long MaxMessageBytes { get; set; } = 20L * 1024 * 1024;
|
||||
|
||||
/// <summary>Maximum number of attachments per message (contract default: 10).</summary>
|
||||
public int MaxCount { get; set; } = 10;
|
||||
}
|
||||
|
||||
@@ -129,4 +129,10 @@ public sealed class EmailTestResult
|
||||
/// (see <see cref="FuchsEmailSettings.OverrideRecipient"/>); otherwise <c>null</c>.
|
||||
/// </summary>
|
||||
public string? OverrideRecipient { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// File name of the PDF that was attached to the test mail, or <c>null</c> when the mail was
|
||||
/// sent without an attachment (or never reached the Mailer).
|
||||
/// </summary>
|
||||
public string? AttachmentName { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using Azure;
|
||||
using Azure.Security.KeyVault.Secrets;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
@@ -28,6 +29,7 @@ public sealed class SystemStatusService : ISystemStatusService
|
||||
private readonly IBlobStorageService _blob;
|
||||
private readonly IMfrClientFactory _mfrFactory;
|
||||
private readonly IComService _comService;
|
||||
private readonly IPdfService _pdf;
|
||||
private readonly ProcessWebComSettings _mailer;
|
||||
private readonly FuchsEmailSettings _email;
|
||||
private readonly AzureBlobStorageSettings _blobSettings;
|
||||
@@ -41,6 +43,7 @@ public sealed class SystemStatusService : ISystemStatusService
|
||||
IBlobStorageService blob,
|
||||
IMfrClientFactory mfrFactory,
|
||||
IComService comService,
|
||||
IPdfService pdf,
|
||||
IOptions<ProcessWebComSettings> mailer,
|
||||
IOptions<FuchsEmailSettings> email,
|
||||
IOptions<AzureBlobStorageSettings> blobSettings,
|
||||
@@ -53,6 +56,7 @@ public sealed class SystemStatusService : ISystemStatusService
|
||||
_blob = blob;
|
||||
_mfrFactory = mfrFactory;
|
||||
_comService = comService;
|
||||
_pdf = pdf;
|
||||
_mailer = mailer.Value;
|
||||
_email = email.Value;
|
||||
_blobSettings = blobSettings.Value;
|
||||
@@ -409,8 +413,11 @@ public sealed class SystemStatusService : ISystemStatusService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>File name of the PDF attached to a test email when <c>attachPdf</c> is requested.</summary>
|
||||
internal const string TestPdfAttachmentName = "Fuchs-Intranet-Testmail.pdf";
|
||||
|
||||
public async Task<EmailTestResult> SendTestEmailAsync(string to, string subject, string body,
|
||||
CancellationToken cancellationToken = default)
|
||||
bool attachPdf = true, CancellationToken cancellationToken = default)
|
||||
{
|
||||
to = (to ?? "").Trim();
|
||||
subject = (subject ?? "").Trim();
|
||||
@@ -422,14 +429,43 @@ public sealed class SystemStatusService : ISystemStatusService
|
||||
// Body arrives as plain text from the Admin form; wrap the (HTML-encoded) lines in <p>.
|
||||
string html = "<p>" + System.Net.WebUtility.HtmlEncode(body).Replace("\r\n", "\n").Replace("\n", "<br/>") + "</p>";
|
||||
|
||||
bool sent = await _comService.SendEmailAsync("admin_test", subject, html, to, "Admin Test", attachments: null);
|
||||
// Optional PDF attachment: exercises the Mailer's multipart/attachment path end-to-end
|
||||
// (ADR 0028 — the send only counts as successful when the Mailer confirms the attachment).
|
||||
// The PDF is the same letter layout the invoices/reminders use, rendered in memory from
|
||||
// the test mail's own subject/body, so no DB or stored document is involved.
|
||||
Dictionary<string, byte[]>? attachments = null;
|
||||
if (attachPdf)
|
||||
{
|
||||
try
|
||||
{
|
||||
attachments = new Dictionary<string, byte[]>
|
||||
{
|
||||
[TestPdfAttachmentName] = await RenderTestPdfAsync(to, subject, body),
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Admin test email: rendering the PDF attachment failed");
|
||||
return new EmailTestResult
|
||||
{
|
||||
Sent = false,
|
||||
Message = $"PDF-Anhang konnte nicht erzeugt werden: {ex.Message}",
|
||||
RequestedRecipient = to,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
bool sent = await _comService.SendEmailAsync("admin_test", subject, html, to, "Admin Test", attachments);
|
||||
|
||||
string? overrideTo = string.IsNullOrWhiteSpace(_email.OverrideRecipient) ? null : _email.OverrideRecipient;
|
||||
string attachmentNote = attachments is null ? "" : $" (mit PDF-Anhang {TestPdfAttachmentName})";
|
||||
string message = sent
|
||||
? (overrideTo is null
|
||||
? "Test-E-Mail wurde an den Mailer übergeben."
|
||||
: $"Test-E-Mail wurde an den Mailer übergeben (durch OverrideRecipient umgeleitet an {overrideTo}).")
|
||||
: "Test-E-Mail wurde vom Mailer nicht akzeptiert (ggf. ist der Mailer deaktiviert).";
|
||||
? $"Test-E-Mail wurde an den Mailer übergeben{attachmentNote}."
|
||||
: $"Test-E-Mail wurde an den Mailer übergeben{attachmentNote} (durch OverrideRecipient umgeleitet an {overrideTo}).")
|
||||
: (attachments is null
|
||||
? "Test-E-Mail wurde vom Mailer nicht akzeptiert (ggf. ist der Mailer deaktiviert)."
|
||||
: "Test-E-Mail wurde vom Mailer nicht akzeptiert oder der PDF-Anhang wurde nicht bestätigt (ggf. ist der Mailer deaktiviert).");
|
||||
|
||||
return new EmailTestResult
|
||||
{
|
||||
@@ -437,6 +473,28 @@ public sealed class SystemStatusService : ISystemStatusService
|
||||
Message = message,
|
||||
RequestedRecipient = to,
|
||||
OverrideRecipient = overrideTo,
|
||||
AttachmentName = attachments is null ? null : TestPdfAttachmentName,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the test mail as a one-page letter (standard Fuchs letterhead) to PDF/A bytes.
|
||||
/// Pure in-memory: text blocks are built from the request, nothing is loaded or persisted.
|
||||
/// </summary>
|
||||
private async Task<byte[]> RenderTestPdfAsync(string to, string subject, string body)
|
||||
{
|
||||
var tb = new FuchsPdf.FdsTextBlocks
|
||||
{
|
||||
Address = new[] { "Test-Empfänger", to },
|
||||
AdminRef = "admin_test",
|
||||
AdminDatumLabel = "Datum",
|
||||
AdminDatumValue = DateTime.Now,
|
||||
AdminProvLabel = "Umgebung",
|
||||
ProvisionPeriod = _environment.EnvironmentName,
|
||||
Subject = subject,
|
||||
Body = body.Replace("\r\n", "\n"),
|
||||
};
|
||||
var doc = await _pdf.WriteLetterAsync(tb, draft: false);
|
||||
return _pdf.DocToPdfBytes(doc);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"CheckPdfLicense": true
|
||||
},
|
||||
"Mailer": {
|
||||
"BaseUrl": "https://api.processweb.de",
|
||||
"BaseUrl": "https://processweb.de",
|
||||
"AccountId": "82d87114-c8c3-4d33-95e5-4c781a9229ab",
|
||||
"Token": "SHWRQGM4C3P6VAFFG6RA",
|
||||
"ServerId": "87AAB12E-29E0-47AB-8DD1-5F5D49B4AC2E",
|
||||
|
||||
@@ -42,11 +42,16 @@
|
||||
"fuchs_intranet_TOTP": "MANAGED_BY_KEYVAULT",
|
||||
"SMS_APIKey": "MANAGED_BY_KEYVAULT",
|
||||
"Mailer": {
|
||||
"BaseUrl": "https://api.processweb.de",
|
||||
"BaseUrl": "https://processweb.de",
|
||||
"AccountId": "7da3c1e6-4180-42d6-9a60-5a09d005cce6",
|
||||
"Token": "MANAGED_BY_KEYVAULT",
|
||||
"ServerId": "1192f3bf-57e5-4fd2-bb5d-3b7b983a2d98",
|
||||
"Enabled": true
|
||||
"Enabled": true,
|
||||
"Attachments": {
|
||||
"MaxFileBytes": 10485760,
|
||||
"MaxMessageBytes": 20971520,
|
||||
"MaxCount": 10
|
||||
}
|
||||
},
|
||||
"Email": {
|
||||
"OverrideRecipient": ""
|
||||
|
||||
@@ -173,6 +173,25 @@
|
||||
],
|
||||
"minify": { "enabled": true }
|
||||
},
|
||||
{
|
||||
"context": "intranet:specialties",
|
||||
"outputFileName": "wwwroot/web/fis.specialties.de.js",
|
||||
"inputFiles": [
|
||||
"js/intranet/modules/fis.specialties_txt_de.js",
|
||||
"js/intranet/modules/fis.specialties.js"
|
||||
],
|
||||
"minify": { "enabled": true }
|
||||
},
|
||||
{
|
||||
"context": "intranet:specialties",
|
||||
"outputFileName": "wwwroot/web/fis.specialties.min.css",
|
||||
"inputFiles": [
|
||||
"css/intranet/oci_variables.scss",
|
||||
"css/intranet/fis_variables.scss",
|
||||
"js/intranet/modules/fis.specialties.scss"
|
||||
],
|
||||
"minify": { "enabled": true }
|
||||
},
|
||||
{
|
||||
"context": "intranet:usr",
|
||||
"outputFileName": "wwwroot/web/fis.usr.de.js",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
$fis.notifications.init();
|
||||
$fis.draft.init();
|
||||
$fis.ov();
|
||||
$fis.addAdminMenuButtonsIfAuthorized();
|
||||
$fis.addAdminMenuButtonsIfAuthorized().then($fis.openModuleFromPath);
|
||||
});
|
||||
|
||||
@@ -12,21 +12,23 @@
|
||||
})();
|
||||
|
||||
/* "Administration" is a single sidemenu dropdown (ocmsmenu supports nested items via `itm`)
|
||||
containing three buttons — Benutzerverwaltung (fn=usr), Gruppenverwaltung (fn=usrgrp) and
|
||||
Systemüberwachung (fn=admin) — all restricted to users whose fds_sys authorization exceeds 4.
|
||||
One auth check gates the whole dropdown; it (and therefore all module scripts) is only added
|
||||
after that check resolves, so lower-privileged users never see it or fetch any module's code.
|
||||
Called from fis_main_go.js once the DOM (and the initial main menu) is ready. */
|
||||
containing four buttons — Benutzerverwaltung (fn=usr), Gruppenverwaltung (fn=usrgrp),
|
||||
Systemüberwachung (fn=admin) and Spezialfunktionen (fn=specialties) — all restricted to users
|
||||
whose fds_sys authorization exceeds 4. One auth check gates the whole dropdown; it (and
|
||||
therefore all module scripts) is only added after that check resolves, so lower-privileged
|
||||
users never see it or fetch any module's code. Called from fis_main_go.js once the DOM (and
|
||||
the initial main menu) is ready. Returns a promise that settles once the menu is final. */
|
||||
$fis.addAdminMenuButtonsIfAuthorized = function () {
|
||||
if ($('#m_admin_grp').length > 0) { return; } // idempotent
|
||||
$fis.getAuth('fds_sys').then(function (auth) {
|
||||
if ($('#m_admin_grp').length > 0) { return Promise.resolve(); } // idempotent
|
||||
return $fis.getAuth('fds_sys').then(function (auth) {
|
||||
if ((auth || -1) > 4 && $('#m_admin_grp').length === 0) {
|
||||
$ocms.ocmsmenu.push({
|
||||
lbl: $t.m_admin, id: 'm_admin_grp', ico: 'glyphicon glyphicon-cog',
|
||||
itm: [
|
||||
{ lbl: $t.m_usr, id: 'm_usr', fnc: 'init:usr', ico: 'glyphicon glyphicon-user' },
|
||||
{ lbl: $t.m_usrgrp, id: 'm_usrgrp', fnc: 'init:usrgrp', ico: 'glyphicon glyphicon-list' },
|
||||
{ lbl: $t.m_admin_status, id: 'm_admin', fnc: 'init:admin', ico: 'glyphicon glyphicon-dashboard' }
|
||||
{ lbl: $t.m_admin_status, id: 'm_admin', fnc: 'init:admin', ico: 'glyphicon glyphicon-dashboard' },
|
||||
{ lbl: $t.m_specialties, id: 'm_specialties', fnc: 'init:specialties', ico: 'glyphicon glyphicon-wrench' }
|
||||
]
|
||||
});
|
||||
/* Re-render the main menu cleanly: drop the previously generated menu list
|
||||
@@ -35,4 +37,23 @@ $fis.addAdminMenuButtonsIfAuthorized = function () {
|
||||
$('#mainmenu').ocmsmenu($ocms.ocmsmenu);
|
||||
}
|
||||
}).catch(function () { /* auth lookup failed → leave the menu unchanged */ });
|
||||
};
|
||||
|
||||
/* Deep link: the server renders the same SPA shell for every /{fn} URL (IntranetController.Index
|
||||
ignores fn), so opening e.g. /specialties directly would otherwise just show the dashboard.
|
||||
Opens the module named by the first path segment — but only one the user's menu actually
|
||||
offers (`init:<fn>` item, incl. the admin dropdown), so arbitrary paths, /do/… links and
|
||||
pre-auth flows never trigger a module load. Must run after the admin menu settled. */
|
||||
$fis.openModuleFromPath = function () {
|
||||
let fn = (window.location.pathname.split('/')[1] || '').toLowerCase();
|
||||
if (!/^[a-z]+$/.test(fn) || fn === 'home') { return; }
|
||||
let offered = false;
|
||||
let scan = function (items) {
|
||||
$.each(items || [], function (i, it) {
|
||||
if (it.fnc === 'init:' + fn) { offered = true; }
|
||||
if (it.itm) { scan(it.itm); }
|
||||
});
|
||||
};
|
||||
scan($ocms.ocmsmenu);
|
||||
if (offered) { $ocms.init(fn); }
|
||||
};
|
||||
@@ -8,6 +8,7 @@
|
||||
, m_admin_status: 'Systemüberwachung'
|
||||
, m_usr: 'Benutzerverwaltung'
|
||||
, m_usrgrp: 'Gruppenverwaltung'
|
||||
, m_specialties: 'Spezialfunktionen'
|
||||
, rsp: 'Passwort ändern'
|
||||
, pnm: 'Die Passwörter stimmen nicht überein'
|
||||
, cps: 'Das neue Passwort wurde gespeichert.'
|
||||
|
||||
@@ -187,7 +187,7 @@ let $adm = {
|
||||
];
|
||||
if (e.overrideActive === true) { rows.push([$adt.override, e.overrideRecipient]); }
|
||||
// The pill only turns green once the credentials are verified against the Mailer API
|
||||
// (probe 'email', GET /mailer/fc?fn=verify) — mailerEnabled alone is not proof they work.
|
||||
// (probe 'email', GET /api/mailer/verify) — mailerEnabled alone is not proof they work.
|
||||
let card = $adm.probeCard(grid, $adt.mail, 'email', rows, probe);
|
||||
let ft = $$.dc('adm_card_ft', card);
|
||||
$$.bbtn($adt.testmail_btn, 'adm_btn adm_btn_primary').appendTo(ft).click($adm.testMailDlg);
|
||||
@@ -221,11 +221,12 @@ let $adm = {
|
||||
$ocms.dlgform([
|
||||
{ name: 'to', label: $adt.tm_to, type: 'email', required: true, value: ($ocms.auth.email || '') },
|
||||
{ name: 'subject', label: $adt.tm_subject, type: 'string', required: true, value: $adt.tm_default_subject },
|
||||
{ name: 'body', label: $adt.tm_body, type: 'text', required: true, value: $adt.tm_default_body }
|
||||
{ name: 'body', label: $adt.tm_body, type: 'text', required: true, value: $adt.tm_default_body },
|
||||
{ name: 'attachpdf', label: $adt.tm_attachpdf, type: 'bool', required: true, value: true, note: $adt.tm_attachpdf_note }
|
||||
], {
|
||||
title: $adt.testmail,
|
||||
button: $adt.testmail_btn,
|
||||
size: [420, 560],
|
||||
size: [420, 640],
|
||||
addcontent: $$.dc('adm_note').text($adt.tm_hint),
|
||||
url: $ocms.url('admin/testmail'),
|
||||
typedvalues: true,
|
||||
|
||||
@@ -75,6 +75,8 @@ let $adt = {
|
||||
, tm_to: 'Empfänger'
|
||||
, tm_subject: 'Betreff'
|
||||
, tm_body: 'Nachricht'
|
||||
, tm_attachpdf: 'PDF-Anhang mitsenden'
|
||||
, tm_attachpdf_note: 'Hängt ein aus Betreff und Nachricht erzeugtes Test-PDF (Briefbogen-Layout) an und prüft so auch den Anhang-Pfad des Mailers.'
|
||||
, tm_hint: 'Der Versand nutzt die normale E-Mail-Pipeline; bei gesetztem OverrideRecipient wird die Nachricht dorthin umgeleitet.'
|
||||
, tm_sent: 'Test-E-Mail übergeben.'
|
||||
, tm_failed: 'Test-E-Mail konnte nicht gesendet werden.'
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/* "Specialties" — admin-only page (URL /specialties, menu Administration → Spezialfunktionen)
|
||||
that exposes the otherwise hidden maintenance actions as cards with small forms. Each card
|
||||
calls its real action endpoint (which enforces the fds_sys > 4 gate itself); this module only
|
||||
renders and reports. Add a card per action in `cards`. */
|
||||
let $spc = {
|
||||
init2: function () {
|
||||
$ocms.getScript([], function () { $spc.init3(); });
|
||||
},
|
||||
init3: function () {
|
||||
$fis.cf(true);
|
||||
$fis.lf(true);
|
||||
$('#activemodule').text($spt.mdl);
|
||||
$('#topbar').ocmsmenu([]);
|
||||
let frm = $$.dc('spcfrm', $('#contentframe'));
|
||||
$$.dc('spc_intro', frm).text($spt.intro);
|
||||
let grid = $$.dc('spc_grid', frm);
|
||||
$.each($spc.cards, function (i, build) { build(grid); });
|
||||
},
|
||||
|
||||
/* ── building blocks ─────────────────────────────────────────────────── */
|
||||
card: function (grid, title, desc) {
|
||||
let card = $$.dc('spc_card', grid);
|
||||
let hd = $$.dc('spc_card_hd', card);
|
||||
$$.s(title).appendTo(hd);
|
||||
let bd = $$.dc('spc_card_bd', card);
|
||||
if (desc) { $$.dc('spc_card_desc', bd).text(desc); }
|
||||
let form = $('<form/>', { class: 'spc_form', novalidate: 'novalidate' }).appendTo(bd);
|
||||
let result = $$.dc('spc_result', bd).hide();
|
||||
return { card: card, body: bd, form: form, result: result };
|
||||
},
|
||||
field: function (form, name, label, options) {
|
||||
options = options || {};
|
||||
let fg = $$.dc('spc_field', form);
|
||||
let id = 'spc_' + name;
|
||||
$$.lbl(label, { for: id }).appendTo(fg);
|
||||
let inp = $$.i({ id: id, name: name, type: options.type || 'text', placeholder: options.placeholder || '' }).appendTo(fg);
|
||||
if (options.required) { inp.attr('required', 'required'); }
|
||||
if (options.pattern) { inp.attr('pattern', options.pattern); }
|
||||
if (options.note) { $$.dc('spc_field_note', fg).text(options.note); }
|
||||
return inp;
|
||||
},
|
||||
showResult: function (result, ok, lines) {
|
||||
result.empty().attr('class', 'spc_result ' + (ok ? 'spc_result_ok' : 'spc_result_err'));
|
||||
$.each([].concat(lines || []), function (i, l) { if (l) { $$.dc('spc_result_line', result).text(l); } });
|
||||
result.show();
|
||||
},
|
||||
/* Wires a card form: validates, confirms, POSTs, reports. `run(values)` must return a
|
||||
postXT options object without success/error/complete; `report(response)` returns
|
||||
{ ok, lines }. Callbacks run inside jQuery's dispatch and must never throw. */
|
||||
submit: function (c, options) {
|
||||
let btn = $$.bbtn(options.button, 'spc_btn spc_btn_primary').attr('type', 'submit').appendTo($$.dc('spc_actions', c.form));
|
||||
c.form.on('submit', function (ev) {
|
||||
ev.preventDefault();
|
||||
let f = c.form[0];
|
||||
if (typeof f.reportValidity === 'function' && !f.reportValidity()) { return; }
|
||||
let values = c.form.serializeObject(true, { typedvalues: false });
|
||||
if (typeof options.confirm === 'function' && !confirm(options.confirm(values))) { return; }
|
||||
c.result.hide();
|
||||
btn.prop('disabled', true).text($spt.working);
|
||||
$ocms.postXT($.extend(options.run(values), {
|
||||
success: function (r) {
|
||||
let rep = options.report(r || {});
|
||||
$spc.showResult(c.result, rep.ok, rep.lines);
|
||||
},
|
||||
error: function (jqXHR) {
|
||||
$spc.showResult(c.result, false, [$spt.failed, (jqXHR && jqXHR.internalText) || '']);
|
||||
},
|
||||
complete: function () { btn.prop('disabled', false).text(options.button); }
|
||||
}));
|
||||
});
|
||||
},
|
||||
|
||||
/* ── cards ───────────────────────────────────────────────────────────── */
|
||||
cards: [
|
||||
/* Invoice file backup + clear → POST /do/admin/backupclearinvoicefile/<id> (ADR 0027). */
|
||||
function (grid) {
|
||||
let c = $spc.card(grid, $spt.ifc_title, $spt.ifc_desc);
|
||||
$spc.field(c.form, 'id', $spt.ifc_id, { required: true, placeholder: $spt.ifc_id_ph, note: $spt.ifc_id_note, pattern: '[A-Za-z0-9]{1,10}' });
|
||||
$spc.submit(c, {
|
||||
button: $spt.ifc_btn,
|
||||
confirm: function (v) { return $spt.ifc_confirm.replace('{0}', v.id); },
|
||||
run: function (v) {
|
||||
return { url: $ocms.url('admin/backupclearinvoicefile/' + encodeURIComponent((v.id || '').trim())), method: 'post', data: {} };
|
||||
},
|
||||
report: function (r) {
|
||||
let b = r.backup || {};
|
||||
if (b.success === true) {
|
||||
return { ok: true, lines: [$spt.ifc_ok, b.blobBackupUri ? $spt.ifc_ok_blob.replace('{0}', b.blobBackupUri) : $spt.ifc_ok_noblob] };
|
||||
}
|
||||
return { ok: false, lines: [$spt.ifc_reason[b.reason] || ($spt.failed + ' (' + (b.reason || '?') + ')')] };
|
||||
}
|
||||
});
|
||||
}
|
||||
]
|
||||
};
|
||||
let $$spc = { init2: $spc.init2, auth: {} };
|
||||
export default $$spc;
|
||||
@@ -0,0 +1,99 @@
|
||||
.spcfrm {
|
||||
padding: 1.5rem;
|
||||
|
||||
.spc_intro {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: #f3f6fb;
|
||||
border-left: 3px solid $fuchs_blau;
|
||||
font-size: 0.9rem;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.spc_grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(24rem, 1fr));
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.spc_card {
|
||||
border: 1px solid #d5d5d5;
|
||||
border-radius: 0.4rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(50, 50, 50, 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spc_card_hd {
|
||||
padding: 0.6rem 0.85rem;
|
||||
background: $fuchs_blau;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.spc_card_bd {
|
||||
padding: 0.75rem 0.85rem;
|
||||
}
|
||||
|
||||
.spc_card_desc {
|
||||
font-size: 0.88rem;
|
||||
color: #555;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.spc_form {
|
||||
.spc_field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
margin-bottom: 0.6rem;
|
||||
|
||||
label { font-size: 0.85rem; color: #333; font-weight: 600; }
|
||||
|
||||
input {
|
||||
padding: 0.3rem 0.45rem;
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.95rem;
|
||||
|
||||
&:invalid { border-color: #c0341d; }
|
||||
}
|
||||
|
||||
.spc_field_note { font-size: 0.78rem; color: #777; }
|
||||
}
|
||||
|
||||
.spc_actions { text-align: right; margin-top: 0.25rem; }
|
||||
}
|
||||
|
||||
.spc_btn {
|
||||
cursor: pointer;
|
||||
border-radius: 0.28rem;
|
||||
padding: 0.3rem 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid #ababab;
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
|
||||
&.spc_btn_primary {
|
||||
background-color: $fuchs_akzent;
|
||||
border-color: darken($fuchs_akzent, 8%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:disabled { opacity: 0.6; cursor: default; }
|
||||
}
|
||||
|
||||
.spc_result {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.88rem;
|
||||
word-break: break-word;
|
||||
|
||||
.spc_result_line + .spc_result_line { margin-top: 0.25rem; }
|
||||
|
||||
&.spc_result_ok { background: #e2f3da; color: #2f6d18; }
|
||||
&.spc_result_err { background: #f3ded9; color: #c0341d; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
let $spt = {
|
||||
mdl: 'Spezialfunktionen',
|
||||
intro: 'Versteckte Wartungsfunktionen für Administratoren. Jede Aktion wird protokolliert (fds__admin_activity / Anwendungslog).',
|
||||
run: 'Ausführen',
|
||||
working: 'Wird ausgeführt…',
|
||||
failed: 'Die Aktion ist fehlgeschlagen.',
|
||||
|
||||
/* Card: invoice file backup + clear */
|
||||
ifc_title: 'Rechnungsdatei zurücksetzen (Soft-Delete)',
|
||||
ifc_desc: 'Sichert die gespeicherte PDF-Datei einer finalisierten Rechnung (DB-Tabelle fds__invoice_file_backups + Kopie im Blob-Archiv) und entfernt sie aus der Rechnung. Beim nächsten Aufruf des Dokuments wird die PDF neu generiert (ADR 0025/0027).',
|
||||
ifc_id: 'Interne Rechnungs-ID',
|
||||
ifc_id_ph: 'z.B. W7VAL',
|
||||
ifc_id_note: 'Die interne ID (Spalte Id), nicht die Rechnungsnummer R2026-….',
|
||||
ifc_btn: 'Datei sichern und entfernen',
|
||||
ifc_confirm: 'Die Rechnungsdatei von "{0}" wird gesichert und aus der Rechnung entfernt. Fortfahren?',
|
||||
ifc_ok: 'Erledigt: Die Datei wurde gesichert und entfernt. Das Dokument wird beim nächsten Aufruf neu generiert.',
|
||||
ifc_ok_blob: 'Blob-Backup: {0}',
|
||||
ifc_ok_noblob: 'Kein Blob-Backup angelegt (Blob-Archiv deaktiviert, kein Blob vorhanden oder Kopie fehlgeschlagen — siehe Log).',
|
||||
ifc_reason: {
|
||||
invoice_not_found: 'Rechnung nicht gefunden.',
|
||||
not_final: 'Die Rechnung ist nicht finalisiert — nur finalisierte Rechnungen können zurückgesetzt werden (Entwürfe würden sonst dauerhaft als Entwurf gespeichert).',
|
||||
external: 'Externe (MFR-)Dokumente können nicht neu generiert werden.',
|
||||
no_file: 'Es ist keine Datei gespeichert — nichts zu tun.',
|
||||
db_error: 'Datenbankfehler — Details im Anwendungslog (CodeReference AdminBackupClearInvoiceFile).'
|
||||
}
|
||||
};
|
||||
@@ -75,6 +75,8 @@ let $adt = {
|
||||
, tm_to: 'Empfänger'
|
||||
, tm_subject: 'Betreff'
|
||||
, tm_body: 'Nachricht'
|
||||
, tm_attachpdf: 'PDF-Anhang mitsenden'
|
||||
, tm_attachpdf_note: 'Hängt ein aus Betreff und Nachricht erzeugtes Test-PDF (Briefbogen-Layout) an und prüft so auch den Anhang-Pfad des Mailers.'
|
||||
, tm_hint: 'Der Versand nutzt die normale E-Mail-Pipeline; bei gesetztem OverrideRecipient wird die Nachricht dorthin umgeleitet.'
|
||||
, tm_sent: 'Test-E-Mail übergeben.'
|
||||
, tm_failed: 'Test-E-Mail konnte nicht gesendet werden.'
|
||||
@@ -272,7 +274,7 @@ let $adm = {
|
||||
];
|
||||
if (e.overrideActive === true) { rows.push([$adt.override, e.overrideRecipient]); }
|
||||
// The pill only turns green once the credentials are verified against the Mailer API
|
||||
// (probe 'email', GET /mailer/fc?fn=verify) — mailerEnabled alone is not proof they work.
|
||||
// (probe 'email', GET /api/mailer/verify) — mailerEnabled alone is not proof they work.
|
||||
let card = $adm.probeCard(grid, $adt.mail, 'email', rows, probe);
|
||||
let ft = $$.dc('adm_card_ft', card);
|
||||
$$.bbtn($adt.testmail_btn, 'adm_btn adm_btn_primary').appendTo(ft).click($adm.testMailDlg);
|
||||
@@ -306,11 +308,12 @@ let $adm = {
|
||||
$ocms.dlgform([
|
||||
{ name: 'to', label: $adt.tm_to, type: 'email', required: true, value: ($ocms.auth.email || '') },
|
||||
{ name: 'subject', label: $adt.tm_subject, type: 'string', required: true, value: $adt.tm_default_subject },
|
||||
{ name: 'body', label: $adt.tm_body, type: 'text', required: true, value: $adt.tm_default_body }
|
||||
{ name: 'body', label: $adt.tm_body, type: 'text', required: true, value: $adt.tm_default_body },
|
||||
{ name: 'attachpdf', label: $adt.tm_attachpdf, type: 'bool', required: true, value: true, note: $adt.tm_attachpdf_note }
|
||||
], {
|
||||
title: $adt.testmail,
|
||||
button: $adt.testmail_btn,
|
||||
size: [420, 560],
|
||||
size: [420, 640],
|
||||
addcontent: $$.dc('adm_note').text($adt.tm_hint),
|
||||
url: $ocms.url('admin/testmail'),
|
||||
typedvalues: true,
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -2846,6 +2846,7 @@ $.extend($t, {
|
||||
, m_admin_status: 'Systemüberwachung'
|
||||
, m_usr: 'Benutzerverwaltung'
|
||||
, m_usrgrp: 'Gruppenverwaltung'
|
||||
, m_specialties: 'Spezialfunktionen'
|
||||
, rsp: 'Passwort ändern'
|
||||
, pnm: 'Die Passwörter stimmen nicht überein'
|
||||
, cps: 'Das neue Passwort wurde gespeichert.'
|
||||
@@ -3242,21 +3243,23 @@ $fis.draft = {
|
||||
})();
|
||||
|
||||
/* "Administration" is a single sidemenu dropdown (ocmsmenu supports nested items via `itm`)
|
||||
containing three buttons — Benutzerverwaltung (fn=usr), Gruppenverwaltung (fn=usrgrp) and
|
||||
Systemüberwachung (fn=admin) — all restricted to users whose fds_sys authorization exceeds 4.
|
||||
One auth check gates the whole dropdown; it (and therefore all module scripts) is only added
|
||||
after that check resolves, so lower-privileged users never see it or fetch any module's code.
|
||||
Called from fis_main_go.js once the DOM (and the initial main menu) is ready. */
|
||||
containing four buttons — Benutzerverwaltung (fn=usr), Gruppenverwaltung (fn=usrgrp),
|
||||
Systemüberwachung (fn=admin) and Spezialfunktionen (fn=specialties) — all restricted to users
|
||||
whose fds_sys authorization exceeds 4. One auth check gates the whole dropdown; it (and
|
||||
therefore all module scripts) is only added after that check resolves, so lower-privileged
|
||||
users never see it or fetch any module's code. Called from fis_main_go.js once the DOM (and
|
||||
the initial main menu) is ready. Returns a promise that settles once the menu is final. */
|
||||
$fis.addAdminMenuButtonsIfAuthorized = function () {
|
||||
if ($('#m_admin_grp').length > 0) { return; } // idempotent
|
||||
$fis.getAuth('fds_sys').then(function (auth) {
|
||||
if ($('#m_admin_grp').length > 0) { return Promise.resolve(); } // idempotent
|
||||
return $fis.getAuth('fds_sys').then(function (auth) {
|
||||
if ((auth || -1) > 4 && $('#m_admin_grp').length === 0) {
|
||||
$ocms.ocmsmenu.push({
|
||||
lbl: $t.m_admin, id: 'm_admin_grp', ico: 'glyphicon glyphicon-cog',
|
||||
itm: [
|
||||
{ lbl: $t.m_usr, id: 'm_usr', fnc: 'init:usr', ico: 'glyphicon glyphicon-user' },
|
||||
{ lbl: $t.m_usrgrp, id: 'm_usrgrp', fnc: 'init:usrgrp', ico: 'glyphicon glyphicon-list' },
|
||||
{ lbl: $t.m_admin_status, id: 'm_admin', fnc: 'init:admin', ico: 'glyphicon glyphicon-dashboard' }
|
||||
{ lbl: $t.m_admin_status, id: 'm_admin', fnc: 'init:admin', ico: 'glyphicon glyphicon-dashboard' },
|
||||
{ lbl: $t.m_specialties, id: 'm_specialties', fnc: 'init:specialties', ico: 'glyphicon glyphicon-wrench' }
|
||||
]
|
||||
});
|
||||
/* Re-render the main menu cleanly: drop the previously generated menu list
|
||||
@@ -3266,9 +3269,28 @@ $fis.addAdminMenuButtonsIfAuthorized = function () {
|
||||
}
|
||||
}).catch(function () { /* auth lookup failed → leave the menu unchanged */ });
|
||||
};
|
||||
|
||||
/* Deep link: the server renders the same SPA shell for every /{fn} URL (IntranetController.Index
|
||||
ignores fn), so opening e.g. /specialties directly would otherwise just show the dashboard.
|
||||
Opens the module named by the first path segment — but only one the user's menu actually
|
||||
offers (`init:<fn>` item, incl. the admin dropdown), so arbitrary paths, /do/… links and
|
||||
pre-auth flows never trigger a module load. Must run after the admin menu settled. */
|
||||
$fis.openModuleFromPath = function () {
|
||||
let fn = (window.location.pathname.split('/')[1] || '').toLowerCase();
|
||||
if (!/^[a-z]+$/.test(fn) || fn === 'home') { return; }
|
||||
let offered = false;
|
||||
let scan = function (items) {
|
||||
$.each(items || [], function (i, it) {
|
||||
if (it.fnc === 'init:' + fn) { offered = true; }
|
||||
if (it.itm) { scan(it.itm); }
|
||||
});
|
||||
};
|
||||
scan($ocms.ocmsmenu);
|
||||
if (offered) { $ocms.init(fn); }
|
||||
};
|
||||
$(document).ready(function () {
|
||||
$fis.notifications.init();
|
||||
$fis.draft.init();
|
||||
$fis.ov();
|
||||
$fis.addAdminMenuButtonsIfAuthorized();
|
||||
$fis.addAdminMenuButtonsIfAuthorized().then($fis.openModuleFromPath);
|
||||
});
|
||||
|
||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -0,0 +1,120 @@
|
||||
/* basics */
|
||||
/* Color SCHEME */
|
||||
/* #8396bd */
|
||||
/* #e3e6e6 */
|
||||
/* #ffffff */
|
||||
/* #262626 */
|
||||
/* #262626 */
|
||||
/* #e3e6e6 */
|
||||
/* #FFC801 */
|
||||
/* #ffff4a */
|
||||
/* media breaks */
|
||||
/* other */
|
||||
/* mixin */
|
||||
/* basics */
|
||||
/* Color SCHEME */
|
||||
/* #1b4379 #0033b3 */
|
||||
/*neu: #56a532 ; alt: rgb(32,144,119);*/
|
||||
/* 10% sw */
|
||||
.spcfrm {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.spcfrm .spc_intro {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: #f3f6fb;
|
||||
border-left: 3px solid rgb(27, 67, 121);
|
||||
font-size: 0.9rem;
|
||||
color: #444;
|
||||
}
|
||||
.spcfrm .spc_grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(24rem, 1fr));
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
.spcfrm .spc_card {
|
||||
border: 1px solid #d5d5d5;
|
||||
border-radius: 0.4rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(50, 50, 50, 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
.spcfrm .spc_card_hd {
|
||||
padding: 0.6rem 0.85rem;
|
||||
background: rgb(27, 67, 121);
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
.spcfrm .spc_card_bd {
|
||||
padding: 0.75rem 0.85rem;
|
||||
}
|
||||
.spcfrm .spc_card_desc {
|
||||
font-size: 0.88rem;
|
||||
color: #555;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.spcfrm .spc_form .spc_field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
.spcfrm .spc_form .spc_field label {
|
||||
font-size: 0.85rem;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
.spcfrm .spc_form .spc_field input {
|
||||
padding: 0.3rem 0.45rem;
|
||||
border: 1px solid #bbb;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.spcfrm .spc_form .spc_field input:invalid {
|
||||
border-color: #c0341d;
|
||||
}
|
||||
.spcfrm .spc_form .spc_field .spc_field_note {
|
||||
font-size: 0.78rem;
|
||||
color: #777;
|
||||
}
|
||||
.spcfrm .spc_form .spc_actions {
|
||||
text-align: right;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.spcfrm .spc_btn {
|
||||
cursor: pointer;
|
||||
border-radius: 0.28rem;
|
||||
padding: 0.3rem 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid #ababab;
|
||||
background-color: #fff;
|
||||
color: #333;
|
||||
}
|
||||
.spcfrm .spc_btn.spc_btn_primary {
|
||||
background-color: rgb(86, 165, 50);
|
||||
border-color: rgb(69.68, 133.688372093, 40.511627907);
|
||||
color: #fff;
|
||||
}
|
||||
.spcfrm .spc_btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.spcfrm .spc_result {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 0.6rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.88rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
.spcfrm .spc_result .spc_result_line + .spc_result_line {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.spcfrm .spc_result.spc_result_ok {
|
||||
background: #e2f3da;
|
||||
color: #2f6d18;
|
||||
}
|
||||
.spcfrm .spc_result.spc_result_err {
|
||||
background: #f3ded9;
|
||||
color: #c0341d;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
let $spt = {
|
||||
mdl: 'Spezialfunktionen',
|
||||
intro: 'Versteckte Wartungsfunktionen für Administratoren. Jede Aktion wird protokolliert (fds__admin_activity / Anwendungslog).',
|
||||
run: 'Ausführen',
|
||||
working: 'Wird ausgeführt…',
|
||||
failed: 'Die Aktion ist fehlgeschlagen.',
|
||||
|
||||
/* Card: invoice file backup + clear */
|
||||
ifc_title: 'Rechnungsdatei zurücksetzen (Soft-Delete)',
|
||||
ifc_desc: 'Sichert die gespeicherte PDF-Datei einer finalisierten Rechnung (DB-Tabelle fds__invoice_file_backups + Kopie im Blob-Archiv) und entfernt sie aus der Rechnung. Beim nächsten Aufruf des Dokuments wird die PDF neu generiert (ADR 0025/0027).',
|
||||
ifc_id: 'Interne Rechnungs-ID',
|
||||
ifc_id_ph: 'z.B. W7VAL',
|
||||
ifc_id_note: 'Die interne ID (Spalte Id), nicht die Rechnungsnummer R2026-….',
|
||||
ifc_btn: 'Datei sichern und entfernen',
|
||||
ifc_confirm: 'Die Rechnungsdatei von "{0}" wird gesichert und aus der Rechnung entfernt. Fortfahren?',
|
||||
ifc_ok: 'Erledigt: Die Datei wurde gesichert und entfernt. Das Dokument wird beim nächsten Aufruf neu generiert.',
|
||||
ifc_ok_blob: 'Blob-Backup: {0}',
|
||||
ifc_ok_noblob: 'Kein Blob-Backup angelegt (Blob-Archiv deaktiviert, kein Blob vorhanden oder Kopie fehlgeschlagen — siehe Log).',
|
||||
ifc_reason: {
|
||||
invoice_not_found: 'Rechnung nicht gefunden.',
|
||||
not_final: 'Die Rechnung ist nicht finalisiert — nur finalisierte Rechnungen können zurückgesetzt werden (Entwürfe würden sonst dauerhaft als Entwurf gespeichert).',
|
||||
external: 'Externe (MFR-)Dokumente können nicht neu generiert werden.',
|
||||
no_file: 'Es ist keine Datei gespeichert — nichts zu tun.',
|
||||
db_error: 'Datenbankfehler — Details im Anwendungslog (CodeReference AdminBackupClearInvoiceFile).'
|
||||
}
|
||||
};
|
||||
|
||||
/* "Specialties" — admin-only page (URL /specialties, menu Administration → Spezialfunktionen)
|
||||
that exposes the otherwise hidden maintenance actions as cards with small forms. Each card
|
||||
calls its real action endpoint (which enforces the fds_sys > 4 gate itself); this module only
|
||||
renders and reports. Add a card per action in `cards`. */
|
||||
let $spc = {
|
||||
init2: function () {
|
||||
$ocms.getScript([], function () { $spc.init3(); });
|
||||
},
|
||||
init3: function () {
|
||||
$fis.cf(true);
|
||||
$fis.lf(true);
|
||||
$('#activemodule').text($spt.mdl);
|
||||
$('#topbar').ocmsmenu([]);
|
||||
let frm = $$.dc('spcfrm', $('#contentframe'));
|
||||
$$.dc('spc_intro', frm).text($spt.intro);
|
||||
let grid = $$.dc('spc_grid', frm);
|
||||
$.each($spc.cards, function (i, build) { build(grid); });
|
||||
},
|
||||
|
||||
/* ── building blocks ─────────────────────────────────────────────────── */
|
||||
card: function (grid, title, desc) {
|
||||
let card = $$.dc('spc_card', grid);
|
||||
let hd = $$.dc('spc_card_hd', card);
|
||||
$$.s(title).appendTo(hd);
|
||||
let bd = $$.dc('spc_card_bd', card);
|
||||
if (desc) { $$.dc('spc_card_desc', bd).text(desc); }
|
||||
let form = $('<form/>', { class: 'spc_form', novalidate: 'novalidate' }).appendTo(bd);
|
||||
let result = $$.dc('spc_result', bd).hide();
|
||||
return { card: card, body: bd, form: form, result: result };
|
||||
},
|
||||
field: function (form, name, label, options) {
|
||||
options = options || {};
|
||||
let fg = $$.dc('spc_field', form);
|
||||
let id = 'spc_' + name;
|
||||
$$.lbl(label, { for: id }).appendTo(fg);
|
||||
let inp = $$.i({ id: id, name: name, type: options.type || 'text', placeholder: options.placeholder || '' }).appendTo(fg);
|
||||
if (options.required) { inp.attr('required', 'required'); }
|
||||
if (options.pattern) { inp.attr('pattern', options.pattern); }
|
||||
if (options.note) { $$.dc('spc_field_note', fg).text(options.note); }
|
||||
return inp;
|
||||
},
|
||||
showResult: function (result, ok, lines) {
|
||||
result.empty().attr('class', 'spc_result ' + (ok ? 'spc_result_ok' : 'spc_result_err'));
|
||||
$.each([].concat(lines || []), function (i, l) { if (l) { $$.dc('spc_result_line', result).text(l); } });
|
||||
result.show();
|
||||
},
|
||||
/* Wires a card form: validates, confirms, POSTs, reports. `run(values)` must return a
|
||||
postXT options object without success/error/complete; `report(response)` returns
|
||||
{ ok, lines }. Callbacks run inside jQuery's dispatch and must never throw. */
|
||||
submit: function (c, options) {
|
||||
let btn = $$.bbtn(options.button, 'spc_btn spc_btn_primary').attr('type', 'submit').appendTo($$.dc('spc_actions', c.form));
|
||||
c.form.on('submit', function (ev) {
|
||||
ev.preventDefault();
|
||||
let f = c.form[0];
|
||||
if (typeof f.reportValidity === 'function' && !f.reportValidity()) { return; }
|
||||
let values = c.form.serializeObject(true, { typedvalues: false });
|
||||
if (typeof options.confirm === 'function' && !confirm(options.confirm(values))) { return; }
|
||||
c.result.hide();
|
||||
btn.prop('disabled', true).text($spt.working);
|
||||
$ocms.postXT($.extend(options.run(values), {
|
||||
success: function (r) {
|
||||
let rep = options.report(r || {});
|
||||
$spc.showResult(c.result, rep.ok, rep.lines);
|
||||
},
|
||||
error: function (jqXHR) {
|
||||
$spc.showResult(c.result, false, [$spt.failed, (jqXHR && jqXHR.internalText) || '']);
|
||||
},
|
||||
complete: function () { btn.prop('disabled', false).text(options.button); }
|
||||
}));
|
||||
});
|
||||
},
|
||||
|
||||
/* ── cards ───────────────────────────────────────────────────────────── */
|
||||
cards: [
|
||||
/* Invoice file backup + clear → POST /do/admin/backupclearinvoicefile/<id> (ADR 0027). */
|
||||
function (grid) {
|
||||
let c = $spc.card(grid, $spt.ifc_title, $spt.ifc_desc);
|
||||
$spc.field(c.form, 'id', $spt.ifc_id, { required: true, placeholder: $spt.ifc_id_ph, note: $spt.ifc_id_note, pattern: '[A-Za-z0-9]{1,10}' });
|
||||
$spc.submit(c, {
|
||||
button: $spt.ifc_btn,
|
||||
confirm: function (v) { return $spt.ifc_confirm.replace('{0}', v.id); },
|
||||
run: function (v) {
|
||||
return { url: $ocms.url('admin/backupclearinvoicefile/' + encodeURIComponent((v.id || '').trim())), method: 'post', data: {} };
|
||||
},
|
||||
report: function (r) {
|
||||
let b = r.backup || {};
|
||||
if (b.success === true) {
|
||||
return { ok: true, lines: [$spt.ifc_ok, b.blobBackupUri ? $spt.ifc_ok_blob.replace('{0}', b.blobBackupUri) : $spt.ifc_ok_noblob] };
|
||||
}
|
||||
return { ok: false, lines: [$spt.ifc_reason[b.reason] || ($spt.failed + ' (' + (b.reason || '?') + ')')] };
|
||||
}
|
||||
});
|
||||
}
|
||||
]
|
||||
};
|
||||
let $$spc = { init2: $spc.init2, auth: {} };
|
||||
export default $$spc;
|
||||
+1
@@ -0,0 +1 @@
|
||||
let $spt={mdl:"Spezialfunktionen",intro:"Versteckte Wartungsfunktionen für Administratoren. Jede Aktion wird protokolliert (fds__admin_activity / Anwendungslog).",run:"Ausführen",working:"Wird ausgeführt…",failed:"Die Aktion ist fehlgeschlagen.",ifc_title:"Rechnungsdatei zurücksetzen (Soft-Delete)",ifc_desc:"Sichert die gespeicherte PDF-Datei einer finalisierten Rechnung (DB-Tabelle fds__invoice_file_backups + Kopie im Blob-Archiv) und entfernt sie aus der Rechnung. Beim nächsten Aufruf des Dokuments wird die PDF neu generiert (ADR 0025/0027).",ifc_id:"Interne Rechnungs-ID",ifc_id_ph:"z.B. W7VAL",ifc_id_note:"Die interne ID (Spalte Id), nicht die Rechnungsnummer R2026-….",ifc_btn:"Datei sichern und entfernen",ifc_confirm:'Die Rechnungsdatei von "{0}" wird gesichert und aus der Rechnung entfernt. Fortfahren?',ifc_ok:"Erledigt: Die Datei wurde gesichert und entfernt. Das Dokument wird beim nächsten Aufruf neu generiert.",ifc_ok_blob:"Blob-Backup: {0}",ifc_ok_noblob:"Kein Blob-Backup angelegt (Blob-Archiv deaktiviert, kein Blob vorhanden oder Kopie fehlgeschlagen — siehe Log).",ifc_reason:{invoice_not_found:"Rechnung nicht gefunden.",not_final:"Die Rechnung ist nicht finalisiert — nur finalisierte Rechnungen können zurückgesetzt werden (Entwürfe würden sonst dauerhaft als Entwurf gespeichert).",external:"Externe (MFR-)Dokumente können nicht neu generiert werden.",no_file:"Es ist keine Datei gespeichert — nichts zu tun.",db_error:"Datenbankfehler — Details im Anwendungslog (CodeReference AdminBackupClearInvoiceFile)."}},$spc={init2:function(){$ocms.getScript([],(function(){$spc.init3()}))},init3:function(){$fis.cf(!0),$fis.lf(!0),$("#activemodule").text($spt.mdl),$("#topbar").ocmsmenu([]);let e=$$.dc("spcfrm",$("#contentframe"));$$.dc("spc_intro",e).text($spt.intro);let t=$$.dc("spc_grid",e);$.each($spc.cards,(function(e,n){n(t)}))},card:function(e,t,n){let i=$$.dc("spc_card",e),r=$$.dc("spc_card_hd",i);$$.s(t).appendTo(r);let c=$$.dc("spc_card_bd",i);return n&&$$.dc("spc_card_desc",c).text(n),{card:i,body:c,form:$("<form/>",{class:"spc_form",novalidate:"novalidate"}).appendTo(c),result:$$.dc("spc_result",c).hide()}},field:function(e,t,n,i){i=i||{};let r=$$.dc("spc_field",e),c="spc_"+t;$$.lbl(n,{for:c}).appendTo(r);let o=$$.i({id:c,name:t,type:i.type||"text",placeholder:i.placeholder||""}).appendTo(r);return i.required&&o.attr("required","required"),i.pattern&&o.attr("pattern",i.pattern),i.note&&$$.dc("spc_field_note",r).text(i.note),o},showResult:function(e,t,n){e.empty().attr("class","spc_result "+(t?"spc_result_ok":"spc_result_err")),$.each([].concat(n||[]),(function(t,n){n&&$$.dc("spc_result_line",e).text(n)})),e.show()},submit:function(e,t){let n=$$.bbtn(t.button,"spc_btn spc_btn_primary").attr("type","submit").appendTo($$.dc("spc_actions",e.form));e.form.on("submit",(function(i){i.preventDefault();let r=e.form[0];if("function"==typeof r.reportValidity&&!r.reportValidity())return;let c=e.form.serializeObject(!0,{typedvalues:!1});("function"!=typeof t.confirm||confirm(t.confirm(c)))&&(e.result.hide(),n.prop("disabled",!0).text($spt.working),$ocms.postXT($.extend(t.run(c),{success:function(n){let i=t.report(n||{});$spc.showResult(e.result,i.ok,i.lines)},error:function(t){$spc.showResult(e.result,!1,[$spt.failed,t&&t.internalText||""])},complete:function(){n.prop("disabled",!1).text(t.button)}})))}))},cards:[function(e){let t=$spc.card(e,$spt.ifc_title,$spt.ifc_desc);$spc.field(t.form,"id",$spt.ifc_id,{required:!0,placeholder:$spt.ifc_id_ph,note:$spt.ifc_id_note,pattern:"[A-Za-z0-9]{1,10}"}),$spc.submit(t,{button:$spt.ifc_btn,confirm:function(e){return $spt.ifc_confirm.replace("{0}",e.id)},run:function(e){return{url:$ocms.url("admin/backupclearinvoicefile/"+encodeURIComponent((e.id||"").trim())),method:"post",data:{}}},report:function(e){let t=e.backup||{};return!0===t.success?{ok:!0,lines:[$spt.ifc_ok,t.blobBackupUri?$spt.ifc_ok_blob.replace("{0}",t.blobBackupUri):$spt.ifc_ok_noblob]}:{ok:!1,lines:[$spt.ifc_reason[t.reason]||$spt.failed+" ("+(t.reason||"?")+")"]}}})}]},$$spc={init2:$spc.init2,auth:{}};export default $$spc;
|
||||
+1
@@ -0,0 +1 @@
|
||||
.spcfrm{padding:1.5rem}.spcfrm .spc_intro{margin-bottom:1rem;padding:.5rem .6rem;background:#f3f6fb;border-left:3px solid #1b4379;font-size:.9rem;color:#444}.spcfrm .spc_grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(24rem,1fr));gap:1rem;align-items:start}.spcfrm .spc_card{border:1px solid #d5d5d5;border-radius:.4rem;background:#fff;box-shadow:0 1px 3px rgba(50,50,50,.12);overflow:hidden}.spcfrm .spc_card_hd{padding:.6rem .85rem;background:#1b4379;color:#fff;font-weight:700}.spcfrm .spc_card_bd{padding:.75rem .85rem}.spcfrm .spc_card_desc{font-size:.88rem;color:#555;margin-bottom:.75rem}.spcfrm .spc_form .spc_field{display:flex;flex-direction:column;gap:.2rem;margin-bottom:.6rem}.spcfrm .spc_form .spc_field label{font-size:.85rem;color:#333;font-weight:600}.spcfrm .spc_form .spc_field input{padding:.3rem .45rem;border:1px solid #bbb;border-radius:.25rem;font-size:.95rem}.spcfrm .spc_form .spc_field input:invalid{border-color:#c0341d}.spcfrm .spc_form .spc_field .spc_field_note{font-size:.78rem;color:#777}.spcfrm .spc_form .spc_actions{text-align:right;margin-top:.25rem}.spcfrm .spc_btn{cursor:pointer;border-radius:.28rem;padding:.3rem .8rem;font-size:.9rem;border:1px solid #ababab;background-color:#fff;color:#333}.spcfrm .spc_btn.spc_btn_primary{background-color:#56a532;border-color:rgb(69.68,133.688372093,40.511627907);color:#fff}.spcfrm .spc_btn:disabled{opacity:.6;cursor:default}.spcfrm .spc_result{margin-top:.75rem;padding:.5rem .6rem;border-radius:.25rem;font-size:.88rem;word-break:break-word}.spcfrm .spc_result .spc_result_line+.spc_result_line{margin-top:.25rem}.spcfrm .spc_result.spc_result_ok{background:#e2f3da;color:#2f6d18}.spcfrm .spc_result.spc_result_err{background:#f3ded9;color:#c0341d}
|
||||
File diff suppressed because one or more lines are too long
@@ -127,6 +127,7 @@
|
||||
<Build Include="dbo\Tables\fds__mfr_invoicefiles.sql" />
|
||||
<Build Include="dbo\Tables\fds__log.sql" />
|
||||
<Build Include="dbo\Tables\fds__invoices.sql" />
|
||||
<Build Include="dbo\Tables\fds__invoice_file_backups.sql" />
|
||||
<Build Include="dbo\Tables\fds__invoice_servicerequests.sql" />
|
||||
<Build Include="dbo\Tables\fds__invoice_items.sql" />
|
||||
<Build Include="dbo\Tables\fds__invoice_details.sql" />
|
||||
@@ -357,6 +358,7 @@
|
||||
<Build Include="dbo\Stored Procedures\fds__admin_logdebug.sql" />
|
||||
<Build Include="dbo\Stored Procedures\fds__admin_logdebug_ext.sql" />
|
||||
<Build Include="dbo\Stored Procedures\fds__admin_getReportCatalog.sql" />
|
||||
<Build Include="dbo\Stored Procedures\fds__admin_backupAndClearInvoiceFile.sql" />
|
||||
<Build Include="dbo\Stored Procedures\_BackupAndClearInvoiceFile.sql" />
|
||||
<Build Include="dbo\User Defined Types\json_data_maxU.sql" />
|
||||
<Build Include="dbo\User Defined Types\hash_256.sql" />
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
-- =============================================
|
||||
-- Author: Dr. Stefan Ott
|
||||
-- Create date: 2026-09-17
|
||||
-- Description: App-facing counterpart to the manual [dbo].[_BackupAndClearInvoiceFile] maintenance
|
||||
-- tool (see that proc's header comment and Docs/Decisions/0025's "Alternatives
|
||||
-- considered"). That proc is explicitly manual-only and leaves no application log
|
||||
-- entry, so it is never called from here — this is a separate proc for the hidden
|
||||
-- admin/backupclearinvoicefile action (fds_sys > 4 gate, IntranetController.Admin.cs),
|
||||
-- which logs through the normal ILogger/DB-log-sink pipeline (ADR 0024) instead.
|
||||
--
|
||||
-- Copies the invoice's current [file] bytes into [dbo].[fds__invoice_file_backups],
|
||||
-- then clears [file] so the document regenerates on next read (ADR 0025). Both steps
|
||||
-- run in one transaction. Only a finalised, non-external invoice qualifies — the same
|
||||
-- precondition ADR 0025 puts on regeneration: clearing a draft's file would let the
|
||||
-- next read persist a draft render (fds__setInvoiceFile writes WHERE [file] IS NULL)
|
||||
-- that then blocks the real final PDF forever; an external (MFR) document cannot be
|
||||
-- regenerated at all. The calling service checks this too; this is defense in depth.
|
||||
-- An empty OUTPUT rowset means nothing was backed up/cleared (invoice missing, not
|
||||
-- finalised, external, or [file] already NULL) — rows-affected can't be used for this
|
||||
-- because of SET NOCOUNT ON (same reasoning as fds__setInvoiceFile, ADR 0025).
|
||||
-- =============================================
|
||||
CREATE PROCEDURE [dbo].[fds__admin_backupAndClearInvoiceFile]
|
||||
@authuser varchar(25),
|
||||
@Id varchar(10)
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
-- The clear must never commit without its backup row: XACT_ABORT turns any statement-level
|
||||
-- error inside the transaction into a doomed transaction, and the CATCH rolls back and re-raises
|
||||
-- so the caller sees an error instead of an OUTPUT row (same pattern as the mfr__updt__* procs).
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
INSERT INTO [dbo].[fds__admin_activity] ([activity], [authuser], [info])
|
||||
VALUES ('fds__admin_backupAndClearInvoiceFile', @authuser,
|
||||
(SELECT * FROM (VALUES(@Id)) AS z([id]) FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER));
|
||||
|
||||
BEGIN TRY
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
INSERT INTO [dbo].[fds__invoice_file_backups] ([InvoiceId], [DocumentName], [file], [BackedUpBy])
|
||||
SELECT [Id], [DocumentName], [file], @authuser
|
||||
FROM [dbo].[fds__invoices]
|
||||
WHERE [Id] = @Id AND [file] IS NOT NULL
|
||||
AND [DateFinalized] IS NOT NULL AND ISNULL([IsExternal], 0) = 0;
|
||||
|
||||
UPDATE [dbo].[fds__invoices]
|
||||
SET [file] = NULL
|
||||
OUTPUT inserted.[Id]
|
||||
WHERE [Id] = @Id AND [file] IS NOT NULL
|
||||
AND [DateFinalized] IS NOT NULL AND ISNULL([IsExternal], 0) = 0;
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
END TRY
|
||||
BEGIN CATCH
|
||||
IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
|
||||
|
||||
INSERT INTO dbo.[fds__admin_dberrors] ([UserName], [ErrorNumber], [ErrorState], [ErrorSeverity], [ErrorLine], [ErrorProcedure], [ErrorMessage], [ErrorDateTime])
|
||||
VALUES (SUSER_SNAME(), ERROR_NUMBER(), ERROR_STATE(), ERROR_SEVERITY(), ERROR_LINE(), ERROR_PROCEDURE(), ERROR_MESSAGE(), GETDATE());
|
||||
|
||||
THROW;
|
||||
END CATCH;
|
||||
END
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE [dbo].[fds__invoice_file_backups] (
|
||||
[uid] BIGINT IDENTITY (1, 1) NOT NULL,
|
||||
[InvoiceId] VARCHAR (10) NOT NULL,
|
||||
[DocumentName] VARCHAR (50) NULL,
|
||||
[file] VARBINARY (MAX) NOT NULL,
|
||||
[BackedUpAt] DATETIME CONSTRAINT [DF_fds__invoice_file_backups_BackedUpAt] DEFAULT (getutcdate()) NOT NULL,
|
||||
[BackedUpBy] VARCHAR (25) NULL,
|
||||
CONSTRAINT [PK_fds__invoice_file_backups] PRIMARY KEY CLUSTERED ([uid] ASC)
|
||||
);
|
||||
|
||||
GO
|
||||
CREATE NONCLUSTERED INDEX [fds__invoice_file_backups__ix1]
|
||||
ON [dbo].[fds__invoice_file_backups]([InvoiceId] ASC);
|
||||
+1
-1
Submodule OCORE updated: 3137b5601a...9f9b2d2220
+1
-1
Submodule OCORE_web updated: d5021033fe...4eec682f03
Reference in New Issue
Block a user