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, WatchdogHeartbeatService.PingPath, out Uri? uri, out string problem);
Assert.True(ok, problem);
Assert.NotNull(uri);
Assert.EndsWith("/api/watchdog/v1/ping", 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", WatchdogHeartbeatService.PingPath, 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, WatchdogHeartbeatService.PingPath, 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", WatchdogHeartbeatService.PingPath, out Uri? uri, out _);
Assert.True(ok);
Assert.Equal("https://example.de/watchdog/api/watchdog/v1/ping", 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/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;
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 _));
// 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]
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/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());
}
[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);
}
}
}
}