Refactor stored procedure and update project structure
Playwright Tests / test (push) Has been cancelled

- Modified the stored procedure `fds__admin_getReportCatalog.sql` to use the correct schema for `all_objects`.
- Added new folders and projects for `eRechnungLib` in the solution file `Fuchs_Intranet.slnx`, including validation and test projects.
- Updated submodule reference for `OCORE`.
- Added new submodule `eRechnungLib` with initial commit.
This commit is contained in:
2026-07-06 00:01:35 +02:00
parent daac828c19
commit 4abf81cd7d
27 changed files with 1544 additions and 94 deletions
+81 -3
View File
@@ -1,6 +1,7 @@
using System.Diagnostics;
using Fuchs.intranet;
using Fuchs.Observability;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
@@ -13,13 +14,90 @@ namespace Fuchs.Services;
/// </summary>
public class FuchsPdfService : IPdfService
{
/// <summary>
/// Canonical configuration key holding the Spire.PDF license. Sourced from the
/// <c>SpirePdf-License</c> managed secret (Key Vault name <c>fuchs--SpirePdf-License</c>):
/// the secret-management layer strips the <c>fuchs--</c> app prefix and maps <c>-</c> to
/// <c>_</c> per segment, so <c>SpirePdf-License</c> surfaces here as <c>SpirePdf_License</c>.
/// </summary>
internal const string LicenseConfigKey = "SpirePdf_License";
/// <summary>
/// Placeholder appsettings.json carries for the managed secret until Key Vault (or the
/// DPAPI cache) supplies the real value. Treated as "no license configured" so the embedded
/// fallback key is used instead of applying this literal as a bogus Spire license key —
/// mirrors the convention in <see cref="AzureBlobStorageService"/>.
/// </summary>
internal const string UnloadedSecretPlaceholder = "MANAGED_BY_KEYVAULT";
/// <summary>
/// Every config-key spelling the license can realistically surface under, tried in order.
/// The managed-secret mapping yields <see cref="LicenseConfigKey"/>; the other variants cover
/// a value provided verbatim (appsettings), a <c>:</c>-hierarchy, or an app-prefixed key.
/// </summary>
internal static readonly string[] LicenseConfigKeyCandidates =
{
LicenseConfigKey, // SpirePdf_License (managed-secret mapping)
"SpirePdf-License", // verbatim, e.g. appsettings.Development.json
"SpirePdf:License", // ':'-hierarchy variant
"SpirePdfLicense", // no separator
"fuchs:SpirePdf-License", // default KV manager on the full secret name
"Fuchs:SpirePdf_License",
};
/// <summary>
/// Resolves the Spire license value from configuration, tolerating the different key spellings
/// the secret can surface under. Returns <see langword="null"/> when none carry a value;
/// <paramref name="matchedKey"/> reports which candidate matched (or <see langword="null"/>).
/// </summary>
internal static string? ResolveLicenseFromConfiguration(IConfiguration configuration, out string? matchedKey)
{
foreach (var key in LicenseConfigKeyCandidates)
{
string? value = configuration[key];
if (!string.IsNullOrWhiteSpace(value) &&
!string.Equals(value, UnloadedSecretPlaceholder, StringComparison.Ordinal))
{
matchedKey = key;
return value;
}
}
matchedKey = null;
return null;
}
/// <summary>Config keys that look Spire-related, for diagnostics when no candidate matched.</summary>
internal static IEnumerable<string> SpireLikeConfigKeys(IConfiguration configuration) =>
configuration.AsEnumerable()
.Where(kv => kv.Value is not null &&
kv.Key.Contains("spire", StringComparison.OrdinalIgnoreCase))
.Select(kv => kv.Key)
.Distinct(StringComparer.OrdinalIgnoreCase);
private readonly ILogger<FuchsPdfService> _logger;
public FuchsPdfService(ILogger<FuchsPdfService> logger)
public FuchsPdfService(ILogger<FuchsPdfService> logger, IConfiguration configuration)
{
_logger = logger;
FuchsPdf.SetLicense();
_logger.LogDebug("FuchsPdfService initialised (PDF license applied).");
// The license is normally applied once at startup (Program.cs) before any Spire use;
// re-applying here is a harmless safety net. If the managed secret is missing, the
// embedded fallback key is used — which does NOT license current Spire.PDF and leaves
// an evaluation watermark on rendered PDFs, so surface that as a warning.
string? licenseKey = ResolveLicenseFromConfiguration(configuration, out string? matchedKey);
FuchsPdf.SetLicense(licenseKey);
if (string.IsNullOrWhiteSpace(licenseKey))
{
var spireKeys = SpireLikeConfigKeys(configuration).ToArray();
_logger.LogWarning(
"Spire.PDF license not found under any known config key ({Candidates}). " +
"Config keys containing 'spire': [{FoundKeys}]. Using the embedded fallback key — " +
"rendered PDFs may carry the Spire evaluation watermark. Ensure the Key Vault secret " +
"'fuchs--SpirePdf-License' is present and reachable, or set it in appsettings.Development.json.",
string.Join(", ", LicenseConfigKeyCandidates),
spireKeys.Length > 0 ? string.Join(", ", spireKeys) : "(none)");
}
else
_logger.LogInformation("Spire.PDF license applied from config key '{MatchedKey}'.", matchedKey);
}
public Task<Document> WriteLetterAsync(FuchsPdf.FdsTextBlocks textBlocks, bool draft)
+345
View File
@@ -0,0 +1,345 @@
using Azure;
using Azure.Security.KeyVault.Secrets;
using Fuchs.intranet;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Fuchs.Services;
/// <summary>
/// One-shot startup self-test that can verify Key Vault connectivity and optionally
/// send a startup probe email. This service never throws to avoid blocking app startup.
/// </summary>
public class StartupSelfTestService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;
private readonly IConfiguration _configuration;
private readonly StartupSelfTestSettings _settings;
private readonly ILogger<StartupSelfTestService> _logger;
public StartupSelfTestService(
IServiceProvider serviceProvider,
IConfiguration configuration,
IOptions<StartupSelfTestSettings> settings,
ILogger<StartupSelfTestService> logger)
{
_serviceProvider = serviceProvider;
_configuration = configuration;
_settings = settings.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
=> await RunOnceAsync(stoppingToken);
internal async Task RunOnceAsync(CancellationToken stoppingToken)
{
if (!_settings.Enabled)
{
_logger.LogDebug("StartupSelfTestService skipped - Fuchs:StartupChecks:Enabled is false.");
return;
}
bool keyVaultOk = true;
bool databaseOk = true;
bool mfrOk = true;
bool mailerOk = true;
bool pdfLicenseOk = true;
try
{
if (_settings.CheckKeyVault)
{
try
{
keyVaultOk = await ProbeKeyVaultAsync(stoppingToken);
}
catch (Exception ex)
{
keyVaultOk = false;
_logger.LogWarning(ex, "Startup Key Vault check failed with an exception.");
}
}
if (_settings.CheckDatabase)
{
try
{
databaseOk = await ProbeDatabaseAsync(stoppingToken);
}
catch (Exception ex)
{
databaseOk = false;
_logger.LogWarning(ex, "Startup database check failed with an exception.");
}
}
if (_settings.CheckMfr)
{
try
{
mfrOk = await ProbeMfrAsync(stoppingToken);
}
catch (Exception ex)
{
mfrOk = false;
_logger.LogWarning(ex, "Startup MFR check failed with an exception.");
}
}
if (_settings.CheckPdfLicense)
{
try
{
pdfLicenseOk = await ProbePdfLicenseAsync(stoppingToken);
}
catch (Exception ex)
{
pdfLicenseOk = false;
_logger.LogWarning(ex, "Startup PDF license check failed with an exception.");
}
}
if (_settings.SendStartupEmail)
{
try
{
mailerOk = await SendStartupEmailAsync(stoppingToken);
}
catch (Exception ex)
{
mailerOk = false;
_logger.LogWarning(ex, "Startup mailer check failed with an exception.");
}
}
_logger.LogInformation(
"Startup self-test completed. KeyVaultOk={KeyVaultOk}, DatabaseOk={DatabaseOk}, MfrOk={MfrOk}, MailerOk={MailerOk}, PdfLicenseOk={PdfLicenseOk}",
keyVaultOk,
databaseOk,
mfrOk,
mailerOk,
pdfLicenseOk);
}
catch (OperationCanceledException)
{
_logger.LogWarning("Startup self-test canceled.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Startup self-test failed unexpectedly.");
}
}
protected virtual async Task<bool> ProbeDatabaseAsync(CancellationToken cancellationToken)
{
string? connectionString = _configuration.GetConnectionString("fuchs_fds_ConnectionString");
if (string.IsNullOrWhiteSpace(connectionString))
{
_logger.LogWarning("Startup database check skipped - ConnectionStrings:fuchs_fds_ConnectionString is empty.");
return false;
}
try
{
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync(cancellationToken);
await using var command = new SqlCommand("SELECT 1;", connection);
object? scalar = await command.ExecuteScalarAsync(cancellationToken);
bool ok = scalar is not null && scalar.ToString() == "1";
if (!ok)
{
_logger.LogWarning("Startup database check failed - SELECT 1 returned '{Value}'.", scalar);
return false;
}
_logger.LogInformation("Startup database check succeeded.");
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Startup database check failed.");
return false;
}
}
protected virtual async Task<bool> ProbeMfrAsync(CancellationToken cancellationToken)
{
try
{
var factory = _serviceProvider.GetService<IMfrClientFactory>();
if (factory is null)
{
_logger.LogWarning("Startup MFR check skipped - IMfrClientFactory is not registered.");
return false;
}
using var client = factory.Create();
string entities = await client.GetEntities(throwErrorIfNotOk: true);
if (string.IsNullOrWhiteSpace(entities))
{
_logger.LogWarning("Startup MFR check failed - empty response.");
return false;
}
_logger.LogInformation("Startup MFR check succeeded.");
return true;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Startup MFR check failed.");
return false;
}
}
/// <summary>
/// Verifies the Spire.PDF license: the license string must be configured (present and
/// non-empty) and Spire.PDF must actually be licensed. Spire exposes no public validity
/// API, so the licensed state is probed by creating a tiny document and checking the
/// output for the evaluation watermark it stamps when unlicensed.
/// </summary>
protected virtual async Task<bool> ProbePdfLicenseAsync(CancellationToken cancellationToken)
{
string? licenseKey = FuchsPdfService.ResolveLicenseFromConfiguration(_configuration, out string? matchedKey);
if (string.IsNullOrWhiteSpace(licenseKey))
{
var spireKeys = FuchsPdfService.SpireLikeConfigKeys(_configuration).ToArray();
_logger.LogWarning(
"Startup PDF license check failed - no license found under any known config key ({Candidates}). " +
"Config keys containing 'spire': [{FoundKeys}]. Rendered PDFs will carry the Spire evaluation watermark.",
string.Join(", ", FuchsPdfService.LicenseConfigKeyCandidates),
spireKeys.Length > 0 ? string.Join(", ", spireKeys) : "(none)");
return false;
}
_logger.LogInformation("Startup PDF license check - license found under config key '{MatchedKey}'.", matchedKey);
// Ensure the configured key is applied, then confirm Spire is not in evaluation mode.
FuchsPdf.SetLicense(licenseKey);
bool licensed = await Task.Run(SpirePdfIsLicensed, cancellationToken);
if (licensed)
_logger.LogInformation("Startup PDF license check succeeded - Spire.PDF is licensed.");
else
_logger.LogWarning(
"Startup PDF license check failed - Spire.PDF is in evaluation mode. The configured " +
"license key was rejected or does not cover this Spire.PDF version.");
return licensed;
}
/// <summary>
/// Returns <see langword="true"/> when Spire.PDF is licensed. Detects the evaluation edition
/// by rendering a minimal document and checking the extracted text for the watermark Spire
/// stamps on documents it creates while unlicensed.
/// </summary>
internal static bool SpirePdfIsLicensed()
{
try
{
using var doc = new Spire.Pdf.PdfDocument();
var page = doc.Pages.Add();
page.Canvas.DrawString(
"license probe",
new Spire.Pdf.Graphics.PdfFont(Spire.Pdf.Graphics.PdfFontFamily.Helvetica, 10f),
Spire.Pdf.Graphics.PdfBrushes.Black,
10f, 10f);
using var ms = new MemoryStream();
doc.SaveToStream(ms, Spire.Pdf.FileFormat.PDF);
ms.Position = 0;
using var check = new Spire.Pdf.PdfDocument();
check.LoadFromStream(ms);
string text = check.Pages[0].ExtractText();
return !text.Contains("Evaluation Warning", StringComparison.OrdinalIgnoreCase)
&& !text.Contains("created with Spire.PDF", StringComparison.OrdinalIgnoreCase);
}
catch
{
// A malformed/rejected license key makes Spire throw during validation on save;
// any failure to produce a clean licensed document means "not licensed".
return false;
}
}
protected virtual async Task<bool> ProbeKeyVaultAsync(CancellationToken cancellationToken)
{
string appName = _configuration["SecretManagement:AppName"] ?? "";
string[] managedKeys = _configuration.GetSection("SecretManagement:ManagedSecretKeys").Get<string[]>() ?? [];
if (string.IsNullOrWhiteSpace(appName) || managedKeys.Length == 0)
{
_logger.LogWarning("Startup Key Vault check skipped - SecretManagement settings are incomplete.");
return false;
}
var secretClient = _serviceProvider.GetService<SecretClient>();
if (secretClient is null)
{
_logger.LogWarning("Startup Key Vault check skipped - SecretClient is not registered.");
return false;
}
string probeName = $"{appName}--{managedKeys[0]}";
try
{
KeyVaultSecret secret = await secretClient.GetSecretAsync(probeName, version: null, cancellationToken);
if (string.IsNullOrWhiteSpace(secret.Value))
{
_logger.LogWarning("Startup Key Vault check failed - secret '{SecretName}' is empty.", probeName);
return false;
}
_logger.LogInformation("Startup Key Vault check succeeded using '{SecretName}'.", probeName);
return true;
}
catch (RequestFailedException ex)
{
_logger.LogWarning(ex, "Startup Key Vault check failed for '{SecretName}' with status {Status}.", probeName, ex.Status);
return false;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Startup Key Vault check failed for '{SecretName}'.", probeName);
return false;
}
}
protected virtual async Task<bool> SendStartupEmailAsync(CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(_settings.StartupEmailRecipient))
{
_logger.LogWarning("Startup mailer check skipped - StartupEmailRecipient is empty.");
return false;
}
using var scope = _serviceProvider.CreateScope();
var comService = scope.ServiceProvider.GetRequiredService<IComService>();
string subject = $"[Startup] Fuchs Intranet started on {Environment.MachineName}";
string html = $"<p>Fuchs Intranet startup probe.</p>" +
$"<p>UTC: {DateTimeOffset.UtcNow:O}<br/>Machine: {Environment.MachineName}</p>";
bool sent = await comService.SendEmailAsync(
"startup_probe",
subject,
html,
_settings.StartupEmailRecipient,
_settings.StartupEmailRecipientName,
attachments: null);
if (!sent)
{
_logger.LogWarning("Startup mailer check failed - probe email was not accepted by IComService.");
return false;
}
_logger.LogInformation("Startup mailer check succeeded.");
return true;
}
}
+47
View File
@@ -0,0 +1,47 @@
namespace Fuchs.Services;
/// <summary>
/// Optional one-shot startup self-test settings, bound from "Fuchs:StartupChecks".
/// Disabled by default to avoid accidental startup emails in production.
/// </summary>
public class StartupSelfTestSettings
{
public bool Enabled { get; set; } = false;
/// <summary>
/// When enabled, verifies Key Vault access by reading one managed secret.
/// </summary>
public bool CheckKeyVault { get; set; } = true;
/// <summary>
/// When enabled, verifies SQL database connectivity by executing SELECT 1.
/// </summary>
public bool CheckDatabase { get; set; } = true;
/// <summary>
/// When enabled, verifies MFR API connectivity using the configured client credentials.
/// </summary>
public bool CheckMfr { get; set; } = true;
/// <summary>
/// When enabled, verifies that a Spire.PDF license string is configured (present and
/// non-empty) and that Spire.PDF is actually licensed (not running in evaluation mode).
/// </summary>
public bool CheckPdfLicense { get; set; } = true;
/// <summary>
/// When enabled, sends a startup probe email via <see cref="IComService"/>.
/// This applies to all environments, including Production.
/// </summary>
public bool SendStartupEmail { get; set; } = false;
/// <summary>
/// Recipient address for startup probe emails.
/// </summary>
public string StartupEmailRecipient { get; set; } = "";
/// <summary>
/// Recipient display name for startup probe emails.
/// </summary>
public string StartupEmailRecipientName { get; set; } = "Startup Monitor";
}