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);
}
}
}
}