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 { /// /// 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 level. /// 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? 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 _responder; public FakeHandler(Func 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 SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { LastRequest = request; RequestCount++; LastBody = request.Content != null ? await request.Content.ReadAsStringAsync(cancellationToken) : string.Empty; return _responder(request); } } } }