Files
PolyTraderSharp/tests/PolyTrader.Tests/WatchdogHeartbeatTests.cs
T
RichardandClaude Opus 5 ca750a06f5 Watchdog-Heartbeat + LicenseLabrador-Lizenzpruefung eingebunden
Slices W-A/W-B/W-C und L-A aus UMSETZUNGSPLAN-Watchdog-LicenseLabrador-Integration.

Watchdog (Dead-Man's-Switch, externer Server):
- WatchdogHeartbeatService als BackgroundService + DI-Singleton; jeder Sendeversuch
  gekapselt, ein Ausfall des Watchdogs beeintraechtigt PolyTrader nie.
- Eigene Implementierung statt Test-Client des Fremdprojekts: TLS-Pruefung bleibt
  aktiv, http:// nur fuer localhost (Agent-Token nicht im Klartext ins Netz).
- Status aus dem App-Log abgeleitet (Error mit 5-Minuten-Sticky-Fenster, entprellt),
  Lifecycle-Events started/stopping.
- Konfiguration in ServerSettings; Agent-Token [Browsable(false)] mit maskierter
  Eingabe + Statusanzeige, bei gesetztem Master-Key verschluesselt (enc:v1:).

Lizenz (LicenseLabrador, Ed25519):
- LicenseGate.RunStartupGate prueft beim Start; bei ungueltiger Lizenz wird die
  Modulliste leer gebaut, sodass nur die Core-Shell (Terminal/Einstellungen)
  startet. Bewusst kein Environment.Exit - ein Trading-Bot darf nicht mitten im
  Lauf hart sterben. TamperSuspected gilt als nicht nutzbar.
- LicenseDialog (partial + .Designer.cs) fuer Start- und Verwalten-Modus, mit
  Hardware-ID zum Kopieren; Smoke-UI konstruiert beide Modi headless.
- Master-Key wird jetzt VOR dem Host-Build geladen, da auch der Lizenzschluessel
  entschluesselt werden muss; derselbe TerminalLogger wird als Singleton
  weitergereicht, damit die Startmeldungen im Terminal-Fenster erscheinen.

Der Lizenz-SDK-Client wird per Cross-Repo-Projektreferenz auf
..\..\LicenseLabrador eingebunden, damit SDK-Fixes ohne Kopie einfliessen.

484 Zeilen Tests fuer den Heartbeat; Suite gruen (438 Tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 09:04:14 +02:00

485 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
{
/// <summary>
/// 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.
/// </summary>
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 -----
/// <summary>Wartet begrenzt darauf, dass eine Anfrage mit passendem Body abgesetzt wurde.</summary>
private static async Task<bool> 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));
}
/// <summary>Wartet begrenzt darauf, dass der Dienst eine Anfrage abgesetzt hat.</summary>
private static async Task<bool> 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<ServerSettings> 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<HttpRequestMessage, HttpResponseMessage>? responder = null)
{
handler = new FakeHandler(responder ?? (_ => new HttpResponseMessage(HttpStatusCode.OK)));
return new WatchdogHeartbeatService(new TerminalLogger(), new HttpClient(handler), () => settings);
}
/// <summary>Fängt die Anfrage ab, statt sie zu senden.</summary>
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; }
private readonly object _gate = new();
private readonly List<string> _bodies = new();
/// <summary>Thread-sicherer Prädikat-Check über alle bisher gesendeten Bodies.</summary>
public bool AnyBody(Func<string, bool> 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<HttpResponseMessage> 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);
}
}
}
}