Files
PolyTraderSharp/tests/PolyTrader.Tests/WebUi/WebUiSecretsTests.cs
T
RichardandClaude Opus 5 c1e13f5441 WebUI: Server-Einstellungen ohne Geheimnisse bearbeiten
Umsetzung von #561.

Entscheidung Richard, 13.09.2026: Alles, was keine Geheimnisse beruehrt, soll
ueber das WebUI gehen; Geheimnisse ausschliesslich im Desktop.

Eine ausdrueckliche Freigabeliste (ServerSettingsCatalog) statt Reflection. Der
Desktop zeigt per Reflection alles, was ServerSettings hat. Uebernaehme das
WebUI das, stuende jedes kuenftige Feld automatisch im Netz, auch ein
Geheimnis.

Bearbeitbar, je Feld mit Pruefung:
- Farbschema und Zeitzone: nach Neustart
- VPN an/aus und VPN-Standort: sofort
- Blockchain-Listener: nach Neustart
- Polymarket-WSS: sofort
- Watchdog an/aus, Source, Instance und Intervall: sofort

"Sofort" und "nach Neustart" sind am Code belegt, nicht am Feldnamen geraten.
Zeitzone und Blockchain-Listener werden nur beim Start gelesen, das Farbschema
haelt der ThemeManager.

Nur im Desktop, Aenderungsversuch 403 und protokolliert:
- Geheimnisse: Mullvad-Account, Polygon-RPC-URL (enthaelt den Alchemy-Key),
  Watchdog-Token, Lizenzschluessel
- Watchdog-URL: an diese Adresse geht der Watchdog-Token, wer sie aendert,
  bekommt ihn
- Mullvad-CLI-Pfad: dieses Programm wird ausgefuehrt

Von den Geheimnissen zeigt das WebUI nur, ob sie gesetzt sind. OpenRouter- und
Master-Key werden dafuer nicht gelesen.

Gefunden dabei: MullvadVpnService baut die Kommandozeile als eine Zeichenkette
("relay set location <Wert>"). Der VPN-Standort nimmt deshalb nur Mullvad-
Kennungen an (cz, cz-prg). "cz account login 999" wird abgelehnt, ein Test
haelt das fest. Source und Instance gehen als JSON ans Deploymentcenter und
sind auf Kennungszeichen begrenzt.

POST prueft alle Werte zuerst und schreibt dann alles oder nichts, ueber
ServerSettingsFile. Danach zieht er den laufenden Stand nach: das Singleton,
MullvadVpnService.ReloadSettings und WatchdogHeartbeatService.ReloadSettings.

Desktop - bisher schrieb er eine WebUI-Aenderung still zurueck:
Die Einstellungen wurden einmal beim Oeffnen geladen, und "Speichern",
"Watchdog-Token setzen" und "Lizenzschluessel setzen" schrieben jeweils das
ganze alte Objekt zurueck. Jetzt:
- Speichern vergleicht innerhalb der Dateisperre den Dateistand vom Laden und
  verweigert, wenn sich die Datei inzwischen geaendert hat.
- Token und Lizenzschluessel schreiben nur noch ihr eigenes Feld.
- Eine unlesbare Datei sperrt das Speichern, statt Vorgaben zu zeigen, die
  beim Speichern die Schluessel geloescht haetten.

Die WebUI-Ansicht zeigt je Feld den Wirkungsvermerk und schickt nur, was
geaendert wurde. Der Leak-Test legt seine Kanarienwerte jetzt auch in die
Einstellungsdatei, sonst liefe die neue Route gegen eine leere Datei.

Nicht durch Unit-Tests abgedeckt: die Desktop-Aenderung an SettingsWindow
(Oberflaechenklasse). Sie wird mit der Sichtpruefung auf der VM abgenommen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 14:12:00 +02:00

175 lines
7.4 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using PolyTraderSharp.Models;
using Xunit;
namespace PolyTrader.Tests.WebUi
{
/// <summary>
/// Geheimnisse verlassen den Server nie ueber das WebUI (#562).
///
/// <para>Entscheidung Richard, 13.09.2026: Geheimnisse werden ausschliesslich im Desktop verwaltet.
/// Dieser Test haelt das fest, ohne sich auf eine Liste von Routen zu verlassen: Er zaehlt alle
/// GET-Routen selbst auf und prueft jede Antwort gegen eindeutige Kanarienwerte. Eine neue Route,
/// die versehentlich ein Konto oder die Server-Einstellungen serialisiert, faellt damit auf, ohne
/// dass jemand an diesen Test denken muss.</para>
/// </summary>
public class WebUiSecretsTests
{
private const string ApiKeyCanary = "CANARY-APIKEY-4b1f";
private const string ApiSecretCanary = "CANARY-APISECRET-8c2e";
private const string ApiPassphraseCanary = "CANARY-PASSPHRASE-1d7a";
private const string PrivateKeyCanary = "CANARY-PRIVATEKEY-0x9f1e2d3c";
private const string MullvadCanary = "CANARY-MULLVAD-5566";
private const string AlchemyCanary = "CANARY-ALCHEMYKEY-7a7a";
private const string WatchdogCanary = "CANARY-WATCHDOGTOKEN-3e3e";
private const string LicenseCanary = "CANARY-LICENSE-2f2f";
private static readonly string[] Secrets =
{
ApiKeyCanary, ApiSecretCanary, ApiPassphraseCanary, PrivateKeyCanary,
MullvadCanary, AlchemyCanary, WatchdogCanary, LicenseCanary,
WebUiTestHost.Token
};
/// <summary>
/// Ein Live-Konto mit allen Schluesseln und Server-Einstellungen mit allen Geheimnissen. Die
/// Modul-Ablagen sind registriert, damit die Routen echten Code durchlaufen statt frueh
/// mit "nicht verfuegbar" zu antworten.
/// </summary>
private static async Task<WebUiTestHost> StartWithSecretsAsync()
{
var settings = new ServerSettings
{
MullvadAccount = MullvadCanary,
PolygonRpcUrl = "wss://polygon-mainnet.g.alchemy.com/v2/" + AlchemyCanary,
WatchdogToken = WatchdogCanary,
LicenseKey = LicenseCanary
};
var web = await WebUiTestHost.StartAsync(s =>
{
WebUiTestHost.AddCopyTradingStore(s);
WebUiTestHost.AddResolutionFarmingStore(s);
s.AddSingleton(settings);
});
// Auch in die Einstellungsdatei: Die Server-Einstellungen (#561) lesen dort, nicht im Singleton.
settings.Save(web.SettingsPath);
web.State.Accounts[1] = new AccountState
{
AccountId = 1,
Name = "Live-Konto",
WalletAddress = "0x00000000000000000000000000000000000000a1",
ApiKey = ApiKeyCanary,
ApiSecret = ApiSecretCanary,
ApiPassphrase = ApiPassphraseCanary,
PrivateKey = PrivateKeyCanary,
TotalBalance = 1000m,
AvailableBalance = 800m
};
return web;
}
/// <summary>
/// Alle GET-Routen unter /api, mit eingesetzten Parametern. Ausgenommen ist nur der Live-Log-Stream:
/// Server-Sent Events enden nie. Er liefert dieselben Eintraege wie GET /api/logs, und die
/// Route ist abgedeckt.
/// </summary>
private static List<string> ApiGetPaths(WebUiTestHost web)
{
var paths = new List<string>();
foreach (var endpoint in web.Get<EndpointDataSource>().Endpoints.OfType<RouteEndpoint>())
{
string raw = endpoint.RoutePattern.RawText ?? string.Empty;
if (!raw.StartsWith("/api/", StringComparison.OrdinalIgnoreCase)) continue;
if (raw.Contains('*')) continue;
if (raw.EndsWith("/stream", StringComparison.OrdinalIgnoreCase)) continue;
var methods = endpoint.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods;
if (methods == null || !methods.Contains("GET")) continue;
paths.Add(Regex.Replace(raw, @"\{([^}:]+)(:([^}]+))?\}",
m => m.Groups[3].Value is "int" or "long" ? "1" : "x"));
}
return paths;
}
[Fact]
public async Task Keine_GET_Route_liefert_ein_Geheimnis_aus()
{
await using var web = await StartWithSecretsAsync();
var paths = ApiGetPaths(web);
foreach (var path in paths)
{
using var res = await web.Client.GetAsync(path);
string body = await res.Content.ReadAsStringAsync();
foreach (var secret in Secrets)
{
Assert.False(body.Contains(secret, StringComparison.Ordinal),
$"GET {path} ({(int)res.StatusCode}) liefert ein Geheimnis aus: {secret}");
}
}
// Ohne Untergrenze bestuende der Test auch dann, wenn die Aufzaehlung leer bliebe.
Assert.True(paths.Count >= 25, $"Nur {paths.Count} GET-Routen gefunden: {string.Join(", ", paths)}");
}
[Fact]
public async Task Zugangseinstellungen_zeigen_vom_Token_nur_ob_es_gesetzt_ist()
{
await using var web = await WebUiTestHost.StartAsync();
var options = await web.GetJsonAsync("/api/settings/webui");
Assert.True(options.GetProperty("hasToken").GetBoolean());
Assert.False(options.TryGetProperty("token", out _));
Assert.Equal("desktop", options.GetProperty("managedIn").GetString());
}
[Fact]
public async Task Zugangseinstellungen_lassen_sich_ueber_das_WebUI_nicht_aendern()
{
await using var web = await WebUiTestHost.StartAsync();
var (saveStatus, saveBody) = await web.PostJsonAsync("/api/settings/webui",
new { enabled = true, port = 7000, localOnly = false, authToken = "selbst-gewaehlt" });
var (generateStatus, generateBody) = await web.PostJsonAsync("/api/settings/webui/generate-token");
Assert.Equal(403, saveStatus);
Assert.Equal(403, generateStatus);
Assert.Contains("Desktop", saveBody.GetProperty("error").GetString());
Assert.False(generateBody.TryGetProperty("token", out _));
// Nichts davon darf den laufenden Zustand beruehrt haben.
Assert.Equal(WebUiTestHost.Token, web.WebOptions.AuthToken);
Assert.True(web.WebOptions.LocalOnly);
Assert.Equal(6001, web.WebOptions.Port);
}
[Fact]
public async Task Polymarket_Konten_sind_im_WebUI_nur_lesbar()
{
await using var web = await WebUiTestHost.StartAsync();
var accountRoutes = web.Get<EndpointDataSource>().Endpoints.OfType<RouteEndpoint>()
.Where(e => (e.RoutePattern.RawText ?? string.Empty)
.StartsWith("/api/accounts", StringComparison.OrdinalIgnoreCase))
.ToList();
Assert.NotEmpty(accountRoutes);
Assert.All(accountRoutes, e =>
Assert.Equal(new[] { "GET" }, e.Metadata.GetMetadata<HttpMethodMetadata>()!.HttpMethods));
}
}
}