Arbeitsstand gesichert: Deploymentcenter-Integration (D-0 bis D-5) und Einfenster-Shell
Sicherungscommit vor dem Aufraeumen des Repos, damit nachvollziehbar bleibt, welcher Stand vor der Bereinigung galt. Build gruen, 476 Tests gruen. Zwei Straenge, die sich ueber .csproj, Program.cs und appsettings.json ueberschneiden und darum gemeinsam abgelegt werden: Deploymentcenter-Integration (P3c, Plan D-0 bis D-5 code-seitig fertig): - Deploymentcenter.Client 2.5.0 als lokales Paket, Source-Mapping erweitert - DeploymentcenterOptions, DeploymentcenterErrorReporter, LicenseGate/LicenseCli - WatchdogHeartbeatService auf die Deploymentcenter-API umgestellt (version, os, checks, metrics, status stopped) - Security: MasterKeyResolver, SecretRedactor, FilePermissions - Directory.Build.props mit zentraler Version 0.1.0 (Packager-Versionsdisziplin) - deploy/: Packager-Vorlage und systemd-Unit; echte Zugangsdaten bleiben ueber .gitignore aussen vor - setup.json fuer die Erstinstallation - UMSETZUNGSPLAN-Deploymentcenter-Integration.md; ANALYSE-Linux-Portierung.md verweist auf den neuen Plan Einfenster-Shell (UI-Redesign): - ShellWindow + ShellNavModel als Seitenleisten-Shell - WindowMenuBar und WindowMenuModel entfallen - Modul- und Kernfenster auf die Shell-Einbettung angepasst Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PolyTrader.Core.Configuration;
|
||||
using PolyTraderSharp.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace PolyTrader.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Sicherheitsnetz für D-3 (Fehler-Reporting an das Deploymentcenter). Kernpunkte, die hier
|
||||
/// fixiert werden: TLS-Zwang wie beim Watchdog, korrekter Endpunkt/Header, Secrets werden aus
|
||||
/// Nachricht UND Stacktrace entfernt, lokale Ratenbegrenzung, und die drei Fehlerquellen
|
||||
/// (App-Log, AppDomain.UnhandledException, TaskScheduler.UnobservedTaskException) melden mit
|
||||
/// dem jeweils richtigen <c>level</c>.
|
||||
/// </summary>
|
||||
public class DeploymentcenterErrorReporterTests
|
||||
{
|
||||
// ----- Grundfunktion -----
|
||||
|
||||
[Fact]
|
||||
public async Task ReportAsync_posts_expected_payload_and_auth_header()
|
||||
{
|
||||
var reporter = Build(out FakeHandler handler, Enabled());
|
||||
|
||||
bool ok = await reporter.ReportAsync("System.InvalidOperationException", "Etwas kaputt", "at Foo.Bar()", "error");
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("https://dc.example.de/api/errors/v1/report", handler.LastRequest!.RequestUri!.AbsoluteUri);
|
||||
Assert.Equal("Bearer", handler.LastRequest.Headers.Authorization!.Scheme);
|
||||
Assert.Equal("secret-token", handler.LastRequest.Headers.Authorization!.Parameter);
|
||||
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
var root = doc.RootElement;
|
||||
Assert.Equal("polytrader", root.GetProperty("project_slug").GetString());
|
||||
Assert.Equal("System.InvalidOperationException", root.GetProperty("exception").GetString());
|
||||
Assert.Equal("Etwas kaputt", root.GetProperty("message").GetString());
|
||||
Assert.Equal("at Foo.Bar()", root.GetProperty("stack_trace").GetString());
|
||||
Assert.Equal("error", root.GetProperty("level").GetString());
|
||||
Assert.False(string.IsNullOrWhiteSpace(root.GetProperty("build").GetString()));
|
||||
Assert.False(string.IsNullOrWhiteSpace(root.GetProperty("environment").GetString()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportAsync_does_nothing_when_disabled()
|
||||
{
|
||||
var options = Enabled();
|
||||
options.ErrorReportingEnabled = false;
|
||||
var reporter = Build(out FakeHandler handler, options);
|
||||
|
||||
bool ok = await reporter.ReportAsync("X", "msg", null, "error");
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Null(handler.LastRequest);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("http://dc.example.de")]
|
||||
[InlineData("")]
|
||||
public async Task ReportAsync_rejects_insecure_or_missing_url(string baseUrl)
|
||||
{
|
||||
var options = Enabled();
|
||||
options.BaseUrl = baseUrl;
|
||||
var reporter = Build(out FakeHandler handler, options);
|
||||
|
||||
bool ok = await reporter.ReportAsync("X", "msg", null, "error");
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Null(handler.LastRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportAsync_returns_false_instead_of_throwing_on_network_error()
|
||||
{
|
||||
var reporter = Build(out _, Enabled(), _ => throw new HttpRequestException("Kein DNS"));
|
||||
|
||||
bool ok = await reporter.ReportAsync("X", "msg", null, "error");
|
||||
|
||||
Assert.False(ok);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportAsync_returns_false_on_http_error_status()
|
||||
{
|
||||
var reporter = Build(out _, Enabled(),
|
||||
_ => new HttpResponseMessage(HttpStatusCode.Unauthorized));
|
||||
|
||||
bool ok = await reporter.ReportAsync("X", "msg", null, "error");
|
||||
|
||||
Assert.False(ok);
|
||||
}
|
||||
|
||||
// ----- Secrets entfernen -----
|
||||
|
||||
[Fact]
|
||||
public async Task ReportAsync_redacts_secrets_from_message_and_stack_trace()
|
||||
{
|
||||
var reporter = Build(out FakeHandler handler, Enabled());
|
||||
|
||||
await reporter.ReportAsync("X",
|
||||
"Wallet 0xABCDEF0123456789 kaputt, token enc:v1:ZZZZYYYYXXXX",
|
||||
"at Foo(0xDEADBEEFCAFEBABE1234)\nat Bar(enc:v1:AAAABBBBCCCC)",
|
||||
"error");
|
||||
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
string message = doc.RootElement.GetProperty("message").GetString()!;
|
||||
string stack = doc.RootElement.GetProperty("stack_trace").GetString()!;
|
||||
|
||||
Assert.DoesNotContain("ABCDEF0123456789", message);
|
||||
Assert.DoesNotContain("ZZZZYYYY", message);
|
||||
Assert.DoesNotContain("DEADBEEFCAFEBABE", stack);
|
||||
Assert.DoesNotContain("AAAABBBBCCCC", stack);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportAsync_keeps_newlines_in_stack_trace()
|
||||
{
|
||||
// Anders als die kurze Heartbeat-Meldung darf ein Stacktrace mehrzeilig bleiben - sonst
|
||||
// ist er zum Debuggen wertlos.
|
||||
var reporter = Build(out FakeHandler handler, Enabled());
|
||||
|
||||
await reporter.ReportAsync("X", "msg", "at Foo()\nat Bar()\nat Baz()", "error");
|
||||
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
Assert.Contains("\n", doc.RootElement.GetProperty("stack_trace").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportAsync_truncates_very_long_stack_traces()
|
||||
{
|
||||
var reporter = Build(out FakeHandler handler, Enabled());
|
||||
|
||||
await reporter.ReportAsync("X", "msg", new string('a', 10_000), "error");
|
||||
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
Assert.True(doc.RootElement.GetProperty("stack_trace").GetString()!.Length <= 4000);
|
||||
}
|
||||
|
||||
// ----- Ratenbegrenzung -----
|
||||
|
||||
[Fact]
|
||||
public async Task ReportAsync_stops_sending_after_local_rate_limit_is_hit()
|
||||
{
|
||||
var reporter = Build(out FakeHandler handler, Enabled());
|
||||
|
||||
int sent = 0;
|
||||
for (int i = 0; i < 25; i++)
|
||||
{
|
||||
if (await reporter.ReportAsync("X", $"msg {i}", null, "error")) sent++;
|
||||
}
|
||||
|
||||
// Eigene Grenze liegt bei 20/Minute - deutlich unter dem Serverlimit von 60/Minute/IP.
|
||||
Assert.Equal(20, sent);
|
||||
Assert.Equal(20, handler.RequestCount);
|
||||
}
|
||||
|
||||
// ----- Quellen: App-Log -----
|
||||
|
||||
[Fact]
|
||||
public void OnAppLog_reports_error_level_but_ignores_warning_and_info()
|
||||
{
|
||||
var reporter = Build(out FakeHandler handler, Enabled());
|
||||
var logger = new TerminalLogger();
|
||||
|
||||
reporter.OnAppLog(logger, new LogMessageEventArgs("nur eine Warnung", LogLevel.Warning));
|
||||
reporter.OnAppLog(logger, new LogMessageEventArgs("nur Info", LogLevel.Info));
|
||||
Assert.Null(handler.LastRequest);
|
||||
|
||||
reporter.OnAppLog(logger, new LogMessageEventArgs("echter Fehler", LogLevel.Error));
|
||||
|
||||
// OnAppLog ist Fire-and-forget (synchroner Aufrufer darf nicht blockieren).
|
||||
Assert.True(SpinWait.SpinUntil(() => handler.LastRequest != null, TimeSpan.FromSeconds(2)));
|
||||
}
|
||||
|
||||
// ----- Quellen: unbeobachtete Task-Exception -----
|
||||
|
||||
[Fact]
|
||||
public void OnUnobservedTaskException_reports_as_warning_and_marks_observed()
|
||||
{
|
||||
var reporter = Build(out FakeHandler handler, Enabled());
|
||||
var args = new UnobservedTaskExceptionEventArgs(
|
||||
new AggregateException(new InvalidOperationException("Hintergrund kaputt")));
|
||||
|
||||
reporter.OnUnobservedTaskException(null, args);
|
||||
|
||||
Assert.True(SpinWait.SpinUntil(() => handler.LastRequest != null, TimeSpan.FromSeconds(2)));
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
Assert.Equal("warning", doc.RootElement.GetProperty("level").GetString());
|
||||
Assert.True(args.Observed);
|
||||
}
|
||||
|
||||
// ----- Quellen: AppDomain.UnhandledException -----
|
||||
|
||||
[Fact]
|
||||
public void OnUnhandledException_reports_synchronously_as_fatal()
|
||||
{
|
||||
var reporter = Build(out FakeHandler handler, Enabled());
|
||||
var args = new UnhandledExceptionEventArgs(new InvalidOperationException("Absturz"), isTerminating: true);
|
||||
|
||||
// Läuft synchron (der Prozess hat keine Zeit mehr für Fire-and-forget) - nach der
|
||||
// Rückkehr muss die Anfrage also bereits abgesetzt sein.
|
||||
reporter.OnUnhandledException(new object(), args);
|
||||
|
||||
Assert.NotNull(handler.LastRequest);
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
Assert.Equal("fatal", doc.RootElement.GetProperty("level").GetString());
|
||||
Assert.Contains("Absturz", doc.RootElement.GetProperty("message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OnUnhandledException_never_throws_even_if_sending_fails()
|
||||
{
|
||||
var reporter = Build(out _, Enabled(), _ => throw new HttpRequestException("Kein Netz"));
|
||||
var args = new UnhandledExceptionEventArgs(new InvalidOperationException("Absturz"), isTerminating: true);
|
||||
|
||||
var ex = Record.Exception(() => reporter.OnUnhandledException(new object(), args));
|
||||
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
// ----- Helfer -----
|
||||
|
||||
private static DeploymentcenterOptions Enabled() => new()
|
||||
{
|
||||
ErrorReportingEnabled = true,
|
||||
BaseUrl = "https://dc.example.de",
|
||||
Token = "secret-token",
|
||||
ProjectSlug = "polytrader"
|
||||
};
|
||||
|
||||
private static DeploymentcenterErrorReporter Build(
|
||||
out FakeHandler handler,
|
||||
DeploymentcenterOptions options,
|
||||
Func<HttpRequestMessage, HttpResponseMessage>? responder = null)
|
||||
{
|
||||
handler = new FakeHandler(responder ?? (_ => new HttpResponseMessage(HttpStatusCode.OK)));
|
||||
return new DeploymentcenterErrorReporter(new TerminalLogger(), options, new HttpClient(handler));
|
||||
}
|
||||
|
||||
private sealed class FakeHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
|
||||
public FakeHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) => _responder = responder;
|
||||
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
public string? LastBody { get; private set; }
|
||||
public int RequestCount { get; private set; }
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
RequestCount++;
|
||||
LastBody = request.Content != null
|
||||
? await request.Content.ReadAsStringAsync(cancellationToken)
|
||||
: string.Empty;
|
||||
return _responder(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using PolyTrader.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace PolyTrader.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Sicherheitsnetz für D-5/D-10 (Master-Key-Erzeugung bei der Erstinstallation). Der wichtigste
|
||||
/// Fall ist der negative: ein vorhandener Schlüssel darf NIEMALS überschrieben werden - das
|
||||
/// würde jedes bereits verschlüsselte Secret in der Datenbank unwiederbringlich unlesbar machen.
|
||||
/// </summary>
|
||||
public class MasterKeyResolverTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir = Path.Combine(Path.GetTempPath(), "pt-masterkey-tests-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public MasterKeyResolverTests() => Directory.CreateDirectory(_tempDir);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_tempDir, recursive: true); } catch { /* Best effort */ }
|
||||
}
|
||||
|
||||
private string KeyFile => Path.Combine(_tempDir, "master.key");
|
||||
|
||||
[Fact]
|
||||
public void Resolve_prefers_environment_variable_and_never_touches_the_file()
|
||||
{
|
||||
string? result = MasterKeyResolver.Resolve(KeyFile, () => "env-key-value");
|
||||
|
||||
Assert.Equal("env-key-value", result);
|
||||
Assert.False(File.Exists(KeyFile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_reads_existing_file_without_modifying_it()
|
||||
{
|
||||
File.WriteAllText(KeyFile, "existing-key-value");
|
||||
var originalWriteTime = File.GetLastWriteTimeUtc(KeyFile);
|
||||
|
||||
string? result = MasterKeyResolver.Resolve(KeyFile, () => null);
|
||||
|
||||
Assert.Equal("existing-key-value", result);
|
||||
Assert.Equal(originalWriteTime, File.GetLastWriteTimeUtc(KeyFile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_generates_a_new_key_when_neither_variable_nor_file_exist()
|
||||
{
|
||||
bool generatedCallbackFired = false;
|
||||
|
||||
string? result = MasterKeyResolver.Resolve(KeyFile, () => null, onGenerated: _ => generatedCallbackFired = true);
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(result));
|
||||
Assert.True(File.Exists(KeyFile));
|
||||
Assert.True(generatedCallbackFired);
|
||||
Assert.Equal(result, File.ReadAllText(KeyFile).Trim());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_generated_key_decodes_as_a_256_bit_key()
|
||||
{
|
||||
string? result = MasterKeyResolver.Resolve(KeyFile, () => null);
|
||||
|
||||
byte[] bytes = Convert.FromBase64String(result!);
|
||||
Assert.Equal(32, bytes.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_never_regenerates_once_a_file_exists()
|
||||
{
|
||||
string? first = MasterKeyResolver.Resolve(KeyFile, () => null);
|
||||
string? second = MasterKeyResolver.Resolve(KeyFile, () => null);
|
||||
string? third = MasterKeyResolver.Resolve(KeyFile, () => null);
|
||||
|
||||
Assert.Equal(first, second);
|
||||
Assert.Equal(first, third);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_does_not_generate_when_environment_variable_is_set_even_without_a_file()
|
||||
{
|
||||
_ = MasterKeyResolver.Resolve(KeyFile, () => "from-env");
|
||||
|
||||
Assert.False(File.Exists(KeyFile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_restricts_generated_file_to_owner_on_unix()
|
||||
{
|
||||
// Kein separates Skip-Paket eingebunden - unter Windows greift ohnehin die
|
||||
// NTFS-Vererbung des Profils (siehe FilePermissions), dort gibt es nichts zu prüfen.
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
|
||||
|
||||
MasterKeyResolver.Resolve(KeyFile, () => null);
|
||||
|
||||
var mode = File.GetUnixFileMode(KeyFile);
|
||||
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, mode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolve_returns_null_and_warns_instead_of_throwing_when_generation_fails()
|
||||
{
|
||||
// Ein Pfad in einem nicht existierenden Verzeichnis lässt File.WriteAllText scheitern.
|
||||
string badPath = Path.Combine(_tempDir, "does-not-exist", "master.key");
|
||||
string? warning = null;
|
||||
|
||||
string? result = MasterKeyResolver.Resolve(badPath, () => null, onWarning: msg => warning = msg);
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.NotNull(warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,11 +29,11 @@ namespace PolyTrader.Tests
|
||||
[InlineData("http://127.0.0.1")]
|
||||
public void TryBuildEndpoint_accepts_https_and_local_http(string baseUrl)
|
||||
{
|
||||
bool ok = WatchdogHeartbeatService.TryBuildEndpoint(baseUrl, "heartbeat", out Uri? uri, out string problem);
|
||||
bool ok = WatchdogHeartbeatService.TryBuildEndpoint(baseUrl, WatchdogHeartbeatService.PingPath, out Uri? uri, out string problem);
|
||||
|
||||
Assert.True(ok, problem);
|
||||
Assert.NotNull(uri);
|
||||
Assert.EndsWith("/api/heartbeat", uri!.AbsoluteUri);
|
||||
Assert.EndsWith("/api/watchdog/v1/ping", uri!.AbsoluteUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -41,7 +41,7 @@ namespace PolyTrader.Tests
|
||||
{
|
||||
// Über http:// ginge der Agent-Token im Klartext über das Netz.
|
||||
bool ok = WatchdogHeartbeatService.TryBuildEndpoint(
|
||||
"http://watchdog.example.de", "heartbeat", out Uri? uri, out string problem);
|
||||
"http://watchdog.example.de", WatchdogHeartbeatService.PingPath, out Uri? uri, out string problem);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Null(uri);
|
||||
@@ -55,7 +55,7 @@ namespace PolyTrader.Tests
|
||||
[InlineData("ftp://watchdog.example.de")]
|
||||
public void TryBuildEndpoint_rejects_invalid_urls(string baseUrl)
|
||||
{
|
||||
bool ok = WatchdogHeartbeatService.TryBuildEndpoint(baseUrl, "heartbeat", out _, out string problem);
|
||||
bool ok = WatchdogHeartbeatService.TryBuildEndpoint(baseUrl, WatchdogHeartbeatService.PingPath, out _, out string problem);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.NotEmpty(problem);
|
||||
@@ -65,10 +65,10 @@ namespace PolyTrader.Tests
|
||||
public void TryBuildEndpoint_keeps_base_path_of_subdirectory_installs()
|
||||
{
|
||||
bool ok = WatchdogHeartbeatService.TryBuildEndpoint(
|
||||
"https://example.de/watchdog", "heartbeat", out Uri? uri, out _);
|
||||
"https://example.de/watchdog", WatchdogHeartbeatService.PingPath, out Uri? uri, out _);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal("https://example.de/watchdog/api/heartbeat", uri!.AbsoluteUri);
|
||||
Assert.Equal("https://example.de/watchdog/api/watchdog/v1/ping", uri!.AbsoluteUri);
|
||||
}
|
||||
|
||||
// ----- Konfigurationsprüfung -----
|
||||
@@ -113,8 +113,10 @@ namespace PolyTrader.Tests
|
||||
|
||||
Assert.True(result.Success, result.Detail);
|
||||
Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method);
|
||||
Assert.Equal("https://wd.example.de/api/heartbeat", handler.LastRequest.RequestUri!.AbsoluteUri);
|
||||
Assert.Equal("secret-token", Assert.Single(handler.LastRequest.Headers.GetValues("X-Watchdog-Key")));
|
||||
Assert.Equal("https://wd.example.de/api/watchdog/v1/ping", handler.LastRequest.RequestUri!.AbsoluteUri);
|
||||
Assert.Equal("Bearer", handler.LastRequest.Headers.Authorization!.Scheme);
|
||||
Assert.Equal("secret-token", handler.LastRequest.Headers.Authorization!.Parameter);
|
||||
Assert.False(handler.LastRequest.Headers.Contains("X-Watchdog-Key"));
|
||||
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
var root = doc.RootElement;
|
||||
@@ -127,6 +129,65 @@ namespace PolyTrader.Tests
|
||||
// Entscheidend: die Watchdog-API liest "interval" und ignoriert "intervalSeconds" still.
|
||||
Assert.Equal(45, root.GetProperty("interval").GetInt32());
|
||||
Assert.False(root.TryGetProperty("intervalSeconds", out _));
|
||||
|
||||
// D-1: Version aus BuildInfo (Directory.Build.props), nicht mehr aus der Assembly-Version.
|
||||
Assert.Equal(PolyTrader.Core.BuildInfo.Version, root.GetProperty("version").GetString());
|
||||
Assert.False(string.IsNullOrWhiteSpace(root.GetProperty("os").GetString()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendHeartbeatAsync_includes_registered_checks()
|
||||
{
|
||||
var service = Build(out FakeHandler handler, Valid());
|
||||
service.RegisterCheck("db", () => (true, null));
|
||||
service.RegisterCheck("market_feed", () => (false, "Letzter Tick vor 14 min"));
|
||||
|
||||
await service.SendHeartbeatAsync();
|
||||
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
var checks = doc.RootElement.GetProperty("checks");
|
||||
Assert.True(checks.GetProperty("db").GetProperty("ok").GetBoolean());
|
||||
Assert.False(checks.GetProperty("market_feed").GetProperty("ok").GetBoolean());
|
||||
Assert.Equal("Letzter Tick vor 14 min", checks.GetProperty("market_feed").GetProperty("message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendHeartbeatAsync_omits_checks_when_none_registered()
|
||||
{
|
||||
var service = Build(out FakeHandler handler, Valid());
|
||||
await service.SendHeartbeatAsync();
|
||||
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
Assert.True(doc.RootElement.TryGetProperty("checks", out var checksProp));
|
||||
Assert.Equal(JsonValueKind.Null, checksProp.ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendHeartbeatAsync_check_failure_reports_itself_instead_of_throwing()
|
||||
{
|
||||
var service = Build(out FakeHandler handler, Valid());
|
||||
service.RegisterCheck("boom", () => throw new InvalidOperationException("kaputt"));
|
||||
|
||||
var result = await service.SendHeartbeatAsync();
|
||||
|
||||
Assert.True(result.Success, result.Detail); // die werfende Prüfung darf den Versand nicht verhindern
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
var boom = doc.RootElement.GetProperty("checks").GetProperty("boom");
|
||||
Assert.False(boom.GetProperty("ok").GetBoolean());
|
||||
Assert.Contains("kaputt", boom.GetProperty("message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnregisterCheck_removes_a_previously_registered_check()
|
||||
{
|
||||
var service = Build(out FakeHandler handler, Valid());
|
||||
service.RegisterCheck("temp", () => (true, null));
|
||||
service.UnregisterCheck("temp");
|
||||
|
||||
await service.SendHeartbeatAsync();
|
||||
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("checks").ValueKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -363,7 +424,8 @@ namespace PolyTrader.Tests
|
||||
var result = await service.SendEventAsync("started", "info", "Test");
|
||||
|
||||
Assert.True(result.Success, result.Detail);
|
||||
Assert.Equal("https://wd.example.de/api/event", handler.LastRequest!.RequestUri!.AbsoluteUri);
|
||||
Assert.Equal("https://wd.example.de/api/watchdog/v1/event", handler.LastRequest!.RequestUri!.AbsoluteUri);
|
||||
Assert.Equal("Bearer", handler.LastRequest.Headers.Authorization!.Scheme);
|
||||
using var doc = JsonDocument.Parse(handler.LastBody!);
|
||||
Assert.Equal("started", doc.RootElement.GetProperty("kind").GetString());
|
||||
Assert.Equal("polytrader", doc.RootElement.GetProperty("source").GetString());
|
||||
|
||||
Reference in New Issue
Block a user