Add German localization and styling for administration module

- Introduced new JavaScript file `fis.admin_txt_de.js` for German translations of administration-related terms and messages.
- Created `fis.admin.css` for styling the administration interface, including layout, cards, and buttons.
- Added `fis.admin.de.js` for the main functionality of the administration module, implementing features such as system status checks and email testing.
- Minified version of the German JavaScript file created as `fis.admin.de.min.js`.
- Minified CSS file created as `fis.admin.min.css` for optimized loading.
This commit is contained in:
Stefan
2026-07-16 14:59:14 +02:00
parent f6079af0de
commit 8a0ebeeb1e
34 changed files with 2765 additions and 16 deletions
+51 -2
View File
@@ -205,6 +205,54 @@ public class StartupSelfTestServiceTests
Assert.Equal(1, service.MailerCalls);
}
[Fact]
public async Task RunOnce_Disabled_PublishesNotRunReport()
{
var reporter = new StartupCheckReporter();
using var service = new TestableStartupSelfTestService(
new ServiceCollection().BuildServiceProvider(),
CreateConfiguration(),
Options.Create(CreateSettings(enabled: false)),
NullLogger<StartupSelfTestService>.Instance,
reporter);
await service.RunForTestAsync(CancellationToken.None);
Assert.NotNull(reporter.Latest);
Assert.False(reporter.Latest!.Ran);
}
[Fact]
public async Task RunOnce_Enabled_PublishesReportWithPerCheckResults()
{
var reporter = new StartupCheckReporter();
using var service = new TestableStartupSelfTestService(
new ServiceCollection().BuildServiceProvider(),
CreateConfiguration(),
Options.Create(CreateSettings(enabled: true, checkKeyVault: true, checkDatabase: true, checkMfr: false, sendStartupEmail: true, startupRecipient: "ops@example.test", checkPdfLicense: false)),
NullLogger<StartupSelfTestService>.Instance,
reporter)
{
KeyVaultResult = true,
DatabaseResult = false,
MailerResult = true,
};
await service.RunForTestAsync(CancellationToken.None);
var report = reporter.Latest;
Assert.NotNull(report);
Assert.True(report!.Ran);
Assert.NotNull(report.CompletedUtc);
var kv = Assert.Single(report.Items, i => i.Name == "KeyVault");
Assert.True(kv.Enabled && kv.Ok);
var db = Assert.Single(report.Items, i => i.Name == "Database");
Assert.True(db.Enabled);
Assert.False(db.Ok); // intentionally-failing path
var mfr = Assert.Single(report.Items, i => i.Name == "MFR");
Assert.False(mfr.Enabled); // disabled check reported as not-enabled
}
[Fact]
public async Task ExecuteAsync_ProbeThrows_ServiceDoesNotThrowAndContinuesRemainingChecks()
{
@@ -250,8 +298,9 @@ public class StartupSelfTestServiceTests
IServiceProvider serviceProvider,
IConfiguration configuration,
IOptions<StartupSelfTestSettings> settings,
Microsoft.Extensions.Logging.ILogger<StartupSelfTestService> logger)
: base(serviceProvider, configuration, settings, logger)
Microsoft.Extensions.Logging.ILogger<StartupSelfTestService> logger,
StartupCheckReporter? reporter = null)
: base(serviceProvider, configuration, settings, logger, reporter)
{
}
+465
View File
@@ -0,0 +1,465 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
using System.Threading;
using System.Threading.Tasks;
using Fuchs.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
namespace Fuchs.Tests;
/// <summary>
/// Tests for the Admin module's read-only diagnostics service. Covers the passive config
/// snapshot (SQL server parsing, email override reflection, presence flags), each probe's
/// disabled/unconfigured/ok/error outcomes where deterministically reachable without live
/// infrastructure, the test-email pipeline (success/failure/validation + HTML-encoding +
/// override reporting), and the emitted probe telemetry.
/// </summary>
public class SystemStatusServiceTests
{
private const string FuchsMeterName = "Fuchs.Intranet";
private static IConfiguration Config(Dictionary<string, string?>? overrides = null)
{
var dict = new Dictionary<string, string?>
{
["ConnectionStrings:fuchs_fds_ConnectionString"] =
"Data Source=SQLHOST,1433;Initial Catalog=site_fuchs_test;User ID=fuchs_app;password='secret';",
["ConnectionStrings:AzureBlobStorage_ConnectionString"] = "DefaultEndpointsProtocol=https;AccountName=acct;AccountKey=key==;",
["Fuchs:IsTestDeployment"] = "true",
["Fds:MFR_host"] = "portal.mobilefieldreport.com",
["Fds:MFR_UserName"] = "mfruser",
["Fds:SyncEnabled"] = "true",
["SecretManagement:VaultUri"] = "https://vault.example/",
["SecretManagement:AppName"] = "fuchs",
["SecretManagement:ManagedSecretKeys:0"] = "Fuchs--Mailer--Token",
["SecretManagement:ManagedSecretKeys:1"] = "ConnectionStrings--fuchs-fds-password",
};
if (overrides != null)
foreach (var kv in overrides) dict[kv.Key] = kv.Value;
return new ConfigurationBuilder().AddInMemoryCollection(dict).Build();
}
private static SystemStatusService Build(
IConfiguration? config = null,
Mock<IBlobStorageService>? blob = null,
Mock<IMfrClientFactory>? mfr = null,
Mock<IComService>? com = null,
ProcessWebComSettings? mailer = null,
FuchsEmailSettings? email = null,
AzureBlobStorageSettings? blobSettings = null,
IServiceProvider? provider = null,
StartupCheckReporter? startupChecks = null)
{
var env = new Mock<IHostEnvironment>();
env.SetupGet(e => e.EnvironmentName).Returns("Development");
return new SystemStatusService(
config ?? Config(),
env.Object,
provider ?? new ServiceCollection().BuildServiceProvider(),
(blob ?? new Mock<IBlobStorageService>()).Object,
(mfr ?? new Mock<IMfrClientFactory>()).Object,
(com ?? new Mock<IComService>()).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 }),
startupChecks ?? new StartupCheckReporter(),
NullLogger<SystemStatusService>.Instance);
}
// ── GetInfo (passive snapshot) ─────────────────────────────────────────────
[Fact]
public void GetInfo_ParsesConnectionStringAndReflectsConfiguration()
{
var svc = Build();
SystemInfoSnapshot info = svc.GetInfo();
Assert.Equal("Development", info.Environment);
Assert.True(info.IsTestDeployment);
Assert.True(info.Database.Configured);
Assert.Equal("SQLHOST,1433", info.Database.Server);
Assert.Equal("site_fuchs_test", info.Database.Catalog);
Assert.Equal("fuchs_app", info.Database.UserId); // login only, never the password
Assert.True(info.Mfr.CredentialsConfigured);
Assert.Equal("portal.mobilefieldreport.com", info.Mfr.Host);
Assert.True(info.Mfr.SyncEnabled);
Assert.Equal("fuchs", info.KeyVault.AppName);
Assert.Equal(2, info.KeyVault.ManagedSecretCount);
Assert.False(info.KeyVault.ClientRegistered); // empty provider
}
[Fact]
public void GetInfo_NeverLeaksDatabasePassword()
{
var svc = Build();
SystemInfoSnapshot info = svc.GetInfo();
Assert.DoesNotContain("secret", info.Database.UserId ?? "");
Assert.DoesNotContain("secret", info.Database.Server ?? "");
Assert.DoesNotContain("secret", info.Database.Catalog ?? "");
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void GetInfo_EmailOverride_ReflectedOnlyWhenConfigured(bool overrideSet)
{
var email = new FuchsEmailSettings { OverrideRecipient = overrideSet ? "safety@example.test" : "" };
var svc = Build(email: email);
SystemInfoSnapshot info = svc.GetInfo();
Assert.Equal(overrideSet, info.Email.OverrideActive);
Assert.Equal(overrideSet ? "safety@example.test" : null, info.Email.OverrideRecipient);
Assert.True(info.Email.MailerEnabled); // Build() default enables the mailer
Assert.True(info.Email.TokenConfigured);
}
[Fact]
public void GetInfo_StartupChecks_NullWhenServiceHasNotRun()
{
var svc = Build(startupChecks: new StartupCheckReporter()); // nothing set yet
Assert.Null(svc.GetInfo().StartupChecks);
}
[Fact]
public void GetInfo_StartupChecks_ReflectsReporterContents()
{
var reporter = new StartupCheckReporter();
reporter.Set(new StartupCheckReport
{
Ran = true,
CompletedUtc = DateTimeOffset.UtcNow,
MachineName = "BUILD-HOST",
Items =
[
new StartupCheckItem { Name = "Database", Enabled = true, Ok = true },
new StartupCheckItem { Name = "Mailer", Enabled = false, Ok = false },
],
});
var svc = Build(startupChecks: reporter);
var report = svc.GetInfo().StartupChecks;
Assert.NotNull(report);
Assert.True(report!.Ran);
Assert.Equal("BUILD-HOST", report.MachineName);
Assert.Equal(2, report.Items.Count);
Assert.Contains(report.Items, i => i.Name == "Database" && i.Enabled && i.Ok);
}
[Fact]
public void GetInfo_DatabaseUnconfigured_WhenConnectionStringMissing()
{
var config = Config(new() { ["ConnectionStrings:fuchs_fds_ConnectionString"] = "" });
var svc = Build(config);
Assert.False(svc.GetInfo().Database.Configured);
}
// ── Blob probe ─────────────────────────────────────────────────────────────
[Fact]
public async Task ProbeBlob_Disabled_ReturnsDisabled()
{
var blob = new Mock<IBlobStorageService>();
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new BlobConnectivity { Enabled = false });
var svc = Build(blob: blob);
var r = await svc.ProbeAsync("blob");
Assert.Equal("blob", r.Component);
Assert.Equal("disabled", r.Status);
Assert.False(r.Ok);
}
[Fact]
public async Task ProbeBlob_EnabledButUnconfigured_ReturnsUnconfigured()
{
var blob = new Mock<IBlobStorageService>();
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new BlobConnectivity { Enabled = true, Configured = false, Detail = "keine Verbindungszeichenfolge" });
var svc = Build(blob: blob);
var r = await svc.ProbeAsync("blob");
Assert.Equal("unconfigured", r.Status);
Assert.False(r.Ok);
}
[Fact]
public async Task ProbeBlob_Reachable_ReturnsOkWithAccountDetail()
{
var blob = new Mock<IBlobStorageService>();
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new BlobConnectivity { Enabled = true, Configured = true, Reachable = true, AccountName = "acct", Detail = "StorageV2 / Standard_LRS" });
var svc = Build(blob: blob);
var r = await svc.ProbeAsync("blob");
Assert.Equal("ok", r.Status);
Assert.True(r.Ok);
Assert.Contains("acct", r.Detail);
}
[Fact]
public async Task ProbeBlob_Reachable_ReportsFileCountsPerContainerAsMetrics()
{
var blob = new Mock<IBlobStorageService>();
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new BlobConnectivity
{
Enabled = true, Configured = true, Reachable = true, AccountName = "acct",
Detail = "StorageV2 / Standard_LRS",
Containers = new List<BlobContainerCount>
{
new() { Name = "dev-fuchs-invoices", Exists = true, FileCount = 42 },
new() { Name = "dev-fuchs-reminders", Exists = false, FileCount = 0 },
},
});
var svc = Build(blob: blob);
var r = await svc.ProbeAsync("blob");
Assert.Equal("ok", r.Status);
Assert.NotNull(r.Metrics);
Assert.Equal(2, r.Metrics!.Count);
var inv = Assert.Single(r.Metrics, m => m.Label == "dev-fuchs-invoices");
Assert.Equal("42 Dateien", inv.Value);
var rem = Assert.Single(r.Metrics, m => m.Label == "dev-fuchs-reminders");
Assert.Equal("nicht vorhanden", rem.Value);
Assert.Contains("42 Dateien gesamt", r.Message); // total across containers
}
[Fact]
public async Task ProbeBlob_FileCountUnavailable_ReportsMetricWithoutFailing()
{
var blob = new Mock<IBlobStorageService>();
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new BlobConnectivity
{
Enabled = true, Configured = true, Reachable = true, AccountName = "acct",
Containers = new List<BlobContainerCount>
{
new() { Name = "dev-fuchs-invoices", Exists = true, FileCount = -1 },
},
});
var svc = Build(blob: blob);
var r = await svc.ProbeAsync("blob");
Assert.Equal("ok", r.Status);
var inv = Assert.Single(r.Metrics!);
Assert.Equal("Anzahl nicht ermittelbar", inv.Value);
}
[Fact]
public async Task ProbeBlob_ConfiguredButUnreachable_ReturnsError()
{
var blob = new Mock<IBlobStorageService>();
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new BlobConnectivity { Enabled = true, Configured = true, Reachable = false, AccountName = "acct", Detail = "403 Forbidden" });
var svc = Build(blob: blob);
var r = await svc.ProbeAsync("blob");
Assert.Equal("error", r.Status);
Assert.False(r.Ok);
}
// ── Key Vault probe ────────────────────────────────────────────────────────
[Fact]
public async Task ProbeKeyVault_ClientNotRegistered_ReturnsUnconfigured()
{
var svc = Build(); // empty service provider → no SecretClient
var r = await svc.ProbeAsync("keyvault");
Assert.Equal("keyvault", r.Component);
Assert.Equal("unconfigured", r.Status);
Assert.False(r.Ok);
}
[Fact]
public async Task ProbeKeyVault_NoManagedKeys_ReturnsUnconfigured()
{
var config = Config(new()
{
["SecretManagement:ManagedSecretKeys:0"] = null,
["SecretManagement:ManagedSecretKeys:1"] = null,
});
var svc = Build(config);
var r = await svc.ProbeAsync("keyvault");
Assert.Equal("unconfigured", r.Status);
}
// ── MFR probe ──────────────────────────────────────────────────────────────
[Fact]
public async Task ProbeMfr_FactoryThrows_ReturnsError()
{
var mfr = new Mock<IMfrClientFactory>();
mfr.Setup(f => f.Create()).Throws(new InvalidOperationException("no credentials"));
var svc = Build(mfr: mfr);
var r = await svc.ProbeAsync("mfr");
Assert.Equal("mfr", r.Component);
Assert.Equal("error", r.Status);
Assert.False(r.Ok);
Assert.Equal("no credentials", r.Detail);
}
// ── Database probe (unconfigured is deterministic without a live server) ────
[Fact]
public async Task ProbeDatabase_NoConnectionString_ReturnsUnconfigured()
{
var config = Config(new() { ["ConnectionStrings:fuchs_fds_ConnectionString"] = "" });
var svc = Build(config);
var r = await svc.ProbeAsync("database");
Assert.Equal("database", r.Component);
Assert.Equal("unconfigured", r.Status);
Assert.False(r.Ok);
}
// ── Unknown component ──────────────────────────────────────────────────────
[Fact]
public async Task ProbeAsync_UnknownComponent_ReturnsError()
{
var svc = Build();
var r = await svc.ProbeAsync("does-not-exist");
Assert.Equal("error", r.Status);
Assert.False(r.Ok);
}
[Fact]
public async Task ProbeAllAsync_ReturnsOneResultPerComponent()
{
var blob = new Mock<IBlobStorageService>();
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new BlobConnectivity { Enabled = false });
var mfr = new Mock<IMfrClientFactory>();
mfr.Setup(f => f.Create()).Throws(new InvalidOperationException("x"));
var config = Config(new() { ["ConnectionStrings:fuchs_fds_ConnectionString"] = "" });
var svc = Build(config, blob: blob, mfr: mfr);
var results = await svc.ProbeAllAsync();
Assert.Equal(svc.ProbeComponents.Count, results.Count);
foreach (var comp in svc.ProbeComponents)
Assert.Contains(results, r => r.Component == comp);
Assert.All(results, r => Assert.True(r.DurationMs >= 0));
}
[Fact]
public async Task ProbeAsync_EmitsProbeTelemetry()
{
var blob = new Mock<IBlobStorageService>();
blob.Setup(b => b.CheckConnectivityAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(new BlobConnectivity { Enabled = false });
var svc = Build(blob: blob);
long delta = 0;
using var listener = new MeterListener
{
InstrumentPublished = (inst, l) =>
{
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.systemstatus.probes")
l.EnableMeasurementEvents(inst);
}
};
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
listener.Start();
await svc.ProbeAsync("blob");
Assert.True(delta >= 1, "fuchs.systemstatus.probes counter should increment per probe.");
}
// ── Test email ─────────────────────────────────────────────────────────────
[Theory]
[InlineData("", "subj", "body")]
[InlineData("to@example.test", "", "body")]
public async Task SendTestEmailAsync_MissingRequiredFields_DoesNotSend(string to, string subject, string body)
{
var com = new Mock<IComService>();
var svc = Build(com: com);
var r = await svc.SendTestEmailAsync(to, subject, body);
Assert.False(r.Sent);
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[]>>()), Times.Never);
}
[Fact]
public async Task SendTestEmailAsync_Success_SendsHtmlEncodedBodyToRecipient()
{
string? capturedHtml = null, capturedTo = null, capturedSubject = 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[]>?>()))
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?>(
(_, subj, html, to, _, _) => { capturedSubject = subj; capturedHtml = html; capturedTo = to; })
.ReturnsAsync(true);
var svc = Build(com: com);
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Zeile1<script>\nZeile2");
Assert.True(r.Sent);
Assert.Equal("dest@example.test", capturedTo);
Assert.Equal("Betreff", capturedSubject);
Assert.NotNull(capturedHtml);
Assert.Contains("&lt;script&gt;", capturedHtml); // HTML-encoded, not injected
Assert.DoesNotContain("<script>", capturedHtml);
Assert.Contains("<br/>", capturedHtml); // newline → <br/>
Assert.Null(r.OverrideRecipient);
}
[Fact]
public async Task SendTestEmailAsync_OverrideActive_ReportsOverrideRecipient()
{
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[]>?>()))
.ReturnsAsync(true);
var svc = Build(com: com, email: new FuchsEmailSettings { OverrideRecipient = "safety@example.test" });
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body");
Assert.True(r.Sent);
Assert.Equal("dest@example.test", r.RequestedRecipient);
Assert.Equal("safety@example.test", r.OverrideRecipient);
Assert.Contains("safety@example.test", r.Message);
}
[Fact]
public async Task SendTestEmailAsync_MailerRejects_ReturnsNotSent()
{
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[]>?>()))
.ReturnsAsync(false);
var svc = Build(com: com);
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body");
Assert.False(r.Sent);
Assert.Equal("dest@example.test", r.RequestedRecipient);
}
}