Refactor stored procedure and update project structure
Playwright Tests / test (push) Has been cancelled
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:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user