Arbeitsstand gesichert: Deploymentcenter-Integration (D-0 bis D-5) und Einfenster-Shell
Sicherungscommit vor dem Aufraeumen des Repos, damit nachvollziehbar bleibt, welcher Stand vor der Bereinigung galt. Build gruen, 476 Tests gruen. Zwei Straenge, die sich ueber .csproj, Program.cs und appsettings.json ueberschneiden und darum gemeinsam abgelegt werden: Deploymentcenter-Integration (P3c, Plan D-0 bis D-5 code-seitig fertig): - Deploymentcenter.Client 2.5.0 als lokales Paket, Source-Mapping erweitert - DeploymentcenterOptions, DeploymentcenterErrorReporter, LicenseGate/LicenseCli - WatchdogHeartbeatService auf die Deploymentcenter-API umgestellt (version, os, checks, metrics, status stopped) - Security: MasterKeyResolver, SecretRedactor, FilePermissions - Directory.Build.props mit zentraler Version 0.1.0 (Packager-Versionsdisziplin) - deploy/: Packager-Vorlage und systemd-Unit; echte Zugangsdaten bleiben ueber .gitignore aussen vor - setup.json fuer die Erstinstallation - UMSETZUNGSPLAN-Deploymentcenter-Integration.md; ANALYSE-Linux-Portierung.md verweist auf den neuen Plan Einfenster-Shell (UI-Redesign): - ShellWindow + ShellNavModel als Seitenleisten-Shell - WindowMenuBar und WindowMenuModel entfallen - Modul- und Kernfenster auf die Shell-Einbettung angepasst Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <c>level</c>.
|
||||
/// </summary>
|
||||
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<HttpRequestMessage, HttpResponseMessage>? 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<HttpRequestMessage, HttpResponseMessage> _responder;
|
||||
public FakeHandler(Func<HttpRequestMessage, HttpResponseMessage> 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<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
RequestCount++;
|
||||
LastBody = request.Content != null
|
||||
? await request.Content.ReadAsStringAsync(cancellationToken)
|
||||
: string.Empty;
|
||||
return _responder(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user