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>
This commit is contained in:
@@ -0,0 +1,484 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user