using System; using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using PolyTraderSharp.Models; using PolyTraderSharp.Services; using Xunit; namespace PolyTrader.Tests { /// /// Sicherheitsnetz für den Watchdog-Heartbeat (B.1). Die Tests fixieren die drei Eigenschaften, /// die im Review der Fremdprojekte aufgefallen sind: /// 1. TLS-Zwang (der mitgelieferte Watchdog-Testclient deaktiviert die Zertifikatsprüfung), /// 2. Feldname "interval" – die Watchdog-API ignoriert "intervalSeconds" still, /// 3. ein nicht erreichbarer Watchdog darf niemals eine Ausnahme nach außen geben. /// public class WatchdogHeartbeatTests { // ----- TLS-Zwang / URL-Aufbau ----- [Theory] [InlineData("https://watchdog.example.de")] [InlineData("https://watchdog.example.de/")] [InlineData("http://localhost:8080")] [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); Assert.True(ok, problem); Assert.NotNull(uri); Assert.EndsWith("/api/heartbeat", uri!.AbsoluteUri); } [Fact] public void TryBuildEndpoint_rejects_plain_http_to_remote_host() { // Ü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); Assert.False(ok); Assert.Null(uri); Assert.Contains("HTTPS", problem); } [Theory] [InlineData("")] [InlineData(" ")] [InlineData("watchdog.example.de")] // ohne Schema [InlineData("ftp://watchdog.example.de")] public void TryBuildEndpoint_rejects_invalid_urls(string baseUrl) { bool ok = WatchdogHeartbeatService.TryBuildEndpoint(baseUrl, "heartbeat", out _, out string problem); Assert.False(ok); Assert.NotEmpty(problem); } [Fact] public void TryBuildEndpoint_keeps_base_path_of_subdirectory_installs() { bool ok = WatchdogHeartbeatService.TryBuildEndpoint( "https://example.de/watchdog", "heartbeat", out Uri? uri, out _); Assert.True(ok); Assert.Equal("https://example.de/watchdog/api/heartbeat", uri!.AbsoluteUri); } // ----- Konfigurationsprüfung ----- [Fact] public void GetConfigurationProblem_reports_missing_url_token_and_source() { Assert.Contains("URL", Problem(s => s.WatchdogUrl = "")); Assert.Contains("Token", Problem(s => { s.WatchdogUrl = "https://wd.example.de"; s.WatchdogToken = ""; })); Assert.Contains("Source", Problem(s => { s.WatchdogUrl = "https://wd.example.de"; s.WatchdogToken = "tok"; s.WatchdogSource = ""; })); } [Fact] public void GetConfigurationProblem_returns_null_when_ready() { var service = Build(out _, Valid()); Assert.Null(service.GetConfigurationProblem()); } // ----- Payload ----- [Fact] public async Task SendHeartbeatAsync_posts_expected_payload_and_auth_header() { var settings = Valid(); settings.WatchdogSource = "polytrader"; settings.WatchdogInstance = "prod-1"; settings.WatchdogIntervalSeconds = 45; var service = Build(out FakeHandler handler, settings); var result = await service.SendHeartbeatAsync("ok", "Testlauf"); 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"))); using var doc = JsonDocument.Parse(handler.LastBody!); var root = doc.RootElement; Assert.Equal("polytrader", root.GetProperty("source").GetString()); Assert.Equal("prod-1", root.GetProperty("instance").GetString()); Assert.Equal("heartbeat", root.GetProperty("type").GetString()); Assert.Equal("ok", root.GetProperty("status").GetString()); Assert.Equal("Testlauf", root.GetProperty("message").GetString()); // Entscheidend: die Watchdog-API liest "interval" und ignoriert "intervalSeconds" still. Assert.Equal(45, root.GetProperty("interval").GetInt32()); Assert.False(root.TryGetProperty("intervalSeconds", out _)); } [Fact] public async Task SendHeartbeatAsync_clamps_interval_to_minimum() { var settings = Valid(); settings.WatchdogIntervalSeconds = 1; // absurd niedrig -> würde den Watchdog fluten var service = Build(out FakeHandler handler, settings); await service.SendHeartbeatAsync(); using var doc = JsonDocument.Parse(handler.LastBody!); Assert.Equal(10, doc.RootElement.GetProperty("interval").GetInt32()); } [Fact] public async Task SendHeartbeatAsync_falls_back_to_default_instance_when_blank() { var settings = Valid(); settings.WatchdogInstance = " "; var service = Build(out FakeHandler handler, settings); await service.SendHeartbeatAsync(); using var doc = JsonDocument.Parse(handler.LastBody!); Assert.Equal("default", doc.RootElement.GetProperty("instance").GetString()); } // ----- Robustheit: ein toter Watchdog darf PolyTrader nicht beeinträchtigen ----- [Fact] public async Task SendHeartbeatAsync_returns_failure_instead_of_throwing_on_network_error() { var service = Build(out _, Valid(), _ => throw new HttpRequestException("Kein DNS")); var result = await service.SendHeartbeatAsync(); Assert.False(result.Success); Assert.Contains("Kein DNS", result.Detail); } [Fact] public async Task SendHeartbeatAsync_reports_http_error_status_as_failure() { var service = Build(out _, Valid(), _ => new HttpResponseMessage(HttpStatusCode.Unauthorized) { ReasonPhrase = "Unauthorized" }); var result = await service.SendHeartbeatAsync(); Assert.False(result.Success); Assert.Contains("401", result.Detail); } [Fact] public async Task SendHeartbeatAsync_does_not_send_when_misconfigured() { var settings = Valid(); settings.WatchdogUrl = "http://watchdog.example.de"; // kein TLS var service = Build(out FakeHandler handler, settings); var result = await service.SendHeartbeatAsync(); Assert.False(result.Success); Assert.Null(handler.LastRequest); // gar nicht erst gesendet } [Fact] public async Task ExecuteAsync_returns_quietly_when_disabled() { var settings = Valid(); settings.WatchdogEnabled = false; var service = Build(out FakeHandler handler, settings); // StartAsync eines BackgroundService darf bei deaktivierter Konfiguration nicht werfen // und nichts senden. await service.StartAsync(CancellationToken.None); await service.StopAsync(CancellationToken.None); Assert.Null(handler.LastRequest); } [Fact] public async Task ExecuteAsync_does_not_throw_when_enabled_but_misconfigured() { var settings = Valid(); settings.WatchdogEnabled = true; settings.WatchdogToken = ""; // Fehlkonfiguration var service = Build(out FakeHandler handler, settings); await service.StartAsync(CancellationToken.None); await service.StopAsync(CancellationToken.None); Assert.Null(handler.LastRequest); } [Fact] public async Task Running_service_starts_sending_after_being_enabled_without_restart() { // Der Nutzer schaltet den Heartbeat im Settings-Fenster ein: der laufende Dienst muss // das übernehmen, ohne dass PolyTrader neu gestartet werden muss. var settings = Valid(); settings.WatchdogEnabled = false; var service = Build(out FakeHandler handler, settings); await service.StartAsync(CancellationToken.None); try { Assert.Null(handler.LastRequest); // deaktiviert -> nichts gesendet settings.WatchdogEnabled = true; service.ReloadSettings(); // genau das macht der Speichern-Button Assert.True(await WaitForRequestAsync(handler), "Nach dem Aktivieren wurde kein Heartbeat gesendet."); } finally { await service.StopAsync(CancellationToken.None); } } [Fact] public async Task Running_service_stops_sending_after_being_disabled_without_restart() { var settings = Valid(); var service = Build(out FakeHandler handler, settings); await service.StartAsync(CancellationToken.None); try { Assert.True(await WaitForRequestAsync(handler), "Es wurde kein initialer Heartbeat gesendet."); settings.WatchdogEnabled = false; service.ReloadSettings(); handler.Reset(); // Der Takt liegt bei 60 s; entscheidend ist, dass nach dem Ausschalten nichts mehr kommt. await Task.Delay(TimeSpan.FromSeconds(2)); Assert.Null(handler.LastRequest); } finally { await service.StopAsync(CancellationToken.None); } } [Fact] public void ReloadSettings_picks_up_changed_values() { var settings = Valid(); settings.WatchdogUrl = "http://watchdog.example.de"; // zunächst ungültig var service = new WatchdogHeartbeatService(new TerminalLogger(), new HttpClient(new FakeHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))), () => settings); Assert.NotNull(service.GetConfigurationProblem()); settings.WatchdogUrl = "https://watchdog.example.de"; service.ReloadSettings(); Assert.Null(service.GetConfigurationProblem()); } // ----- B.2: Status aus dem Log ----- [Fact] public void DeriveStatus_is_ok_without_errors() { var logger = new TerminalLogger(); var service = new WatchdogHeartbeatService(logger, new HttpClient(new FakeHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))), () => Valid()); Assert.Equal("ok", service.DeriveStatus().status); } [Fact] public void DeriveStatus_reflects_logged_error_and_redacts_secrets() { var logger = new TerminalLogger(); var service = new WatchdogHeartbeatService(logger, new HttpClient(new FakeHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))), () => Valid()); logger.Error("DB-Verbindung tot; wallet 0xDEADBEEFCAFEBABE1234 token enc:v1:AAAABBBBCCCC"); var (status, message) = service.DeriveStatus(); Assert.Equal("error", status); Assert.Contains("DB-Verbindung tot", message); // Secrets müssen entschärft sein (kein Wallet-Hex / kein enc-Token durchreichen). Assert.DoesNotContain("DEADBEEFCAFEBABE", message); Assert.DoesNotContain("AAAABBBBCCCC", message); } [Fact] public void DeriveStatus_ignores_warning_and_info() { var logger = new TerminalLogger(); var service = new WatchdogHeartbeatService(logger, new HttpClient(new FakeHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))), () => Valid()); logger.Warning("nur eine Warnung"); logger.Info("nur Info"); // Warnungen/Infos eskalieren den Monitor bewusst nicht. Assert.Equal("ok", service.DeriveStatus().status); } [Theory] [InlineData("0xABCDEF0123456789 kaputt", "DEF012")] [InlineData("secret enc:v1:ZZZZYYYYXXXX weg", "ZZZZYYYY")] public void Sanitize_removes_secret_material(string input, string mustNotContain) { string cleaned = WatchdogHeartbeatService.Sanitize(input); Assert.DoesNotContain(mustNotContain, cleaned); } [Fact] public void Sanitize_truncates_to_200_chars() { string cleaned = WatchdogHeartbeatService.Sanitize(new string('x', 500)); Assert.True(cleaned.Length <= 200, $"Länge {cleaned.Length}"); } // ----- B.3: Lifecycle-Events ----- [Fact] public async Task SendEventAsync_posts_kind_and_source_to_event_endpoint() { var settings = Valid(); var service = Build(out FakeHandler handler, settings); 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); using var doc = JsonDocument.Parse(handler.LastBody!); Assert.Equal("started", doc.RootElement.GetProperty("kind").GetString()); Assert.Equal("polytrader", doc.RootElement.GetProperty("source").GetString()); } [Fact] public async Task Service_sends_started_then_stopped_graceful_over_its_lifecycle() { var settings = Valid(); var service = Build(out FakeHandler handler, settings); await service.StartAsync(CancellationToken.None); Assert.True(await WaitForBodyContainingAsync(handler, "\"kind\":\"started\""), "Es wurde kein 'started'-Event gesendet."); await service.StopAsync(CancellationToken.None); Assert.True(handler.AnyBody(b => b.Contains("\"kind\":\"stopped_graceful\"")), "Beim Herunterfahren wurde kein 'stopped_graceful'-Event gesendet."); } // ----- Helfer ----- /// Wartet begrenzt darauf, dass eine Anfrage mit passendem Body abgesetzt wurde. private static async Task WaitForBodyContainingAsync(FakeHandler handler, string needle, int timeoutMs = 5000) { for (int waited = 0; waited < timeoutMs; waited += 50) { if (handler.AnyBody(b => b.Contains(needle))) return true; await Task.Delay(50); } return handler.AnyBody(b => b.Contains(needle)); } /// Wartet begrenzt darauf, dass der Dienst eine Anfrage abgesetzt hat. private static async Task WaitForRequestAsync(FakeHandler handler, int timeoutMs = 5000) { for (int waited = 0; waited < timeoutMs; waited += 50) { if (handler.LastRequest != null) return true; await Task.Delay(50); } return handler.LastRequest != null; } private static ServerSettings Valid() => new() { WatchdogEnabled = true, WatchdogUrl = "https://wd.example.de", WatchdogToken = "secret-token", WatchdogSource = "polytrader", WatchdogInstance = "prod-1", WatchdogIntervalSeconds = 60 }; private static string Problem(Action configure) { var settings = Valid(); configure(settings); var service = Build(out _, settings); return service.GetConfigurationProblem() ?? string.Empty; } private static WatchdogHeartbeatService Build( out FakeHandler handler, ServerSettings settings, Func? responder = null) { handler = new FakeHandler(responder ?? (_ => new HttpResponseMessage(HttpStatusCode.OK))); return new WatchdogHeartbeatService(new TerminalLogger(), new HttpClient(handler), () => settings); } /// Fängt die Anfrage ab, statt sie zu senden. private sealed class FakeHandler : HttpMessageHandler { private readonly Func _responder; public FakeHandler(Func responder) => _responder = responder; public HttpRequestMessage? LastRequest { get; private set; } public string? LastBody { get; private set; } private readonly object _gate = new(); private readonly List _bodies = new(); /// Thread-sicherer Prädikat-Check über alle bisher gesendeten Bodies. public bool AnyBody(Func predicate) { lock (_gate) { foreach (var b in _bodies) if (predicate(b)) return true; return false; } } public void Reset() { LastRequest = null; LastBody = null; lock (_gate) _bodies.Clear(); } protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { LastRequest = request; string body = string.Empty; if (request.Content != null) { body = await request.Content.ReadAsStringAsync(cancellationToken); } LastBody = body; lock (_gate) _bodies.Add(body); return _responder(request); } } } }