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>
176 lines
8.0 KiB
C#
176 lines
8.0 KiB
C#
using System;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Text.Json;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using PolyTraderSharp.Models;
|
|
using Xunit;
|
|
|
|
namespace PolyTrader.Tests.WebUi
|
|
{
|
|
/// <summary>
|
|
/// Server-Einstellungen über das WebUI (#561): eine Freigabeliste ohne Geheimnisse, geprüfte Werte,
|
|
/// alles oder nichts.
|
|
/// </summary>
|
|
public class ServerSettingsEndpointTests
|
|
{
|
|
private static ServerSettings WithSecrets() => new()
|
|
{
|
|
LicenseKey = "enc:v1:LIZENZ",
|
|
WatchdogToken = "enc:v1:WATCHDOG",
|
|
MullvadAccount = "1234567890",
|
|
PolygonRpcUrl = "wss://polygon-mainnet.g.alchemy.com/v2/ALCHEMY",
|
|
WatchdogUrl = "https://dc.example",
|
|
VpnLocation = "cz",
|
|
WatchdogIntervalSeconds = 60
|
|
};
|
|
|
|
private static JsonElement Field(JsonElement settings, string key) =>
|
|
settings.GetProperty("sections").EnumerateArray()
|
|
.SelectMany(s => s.GetProperty("fields").EnumerateArray())
|
|
.Single(f => f.GetProperty("key").GetString() == key);
|
|
|
|
private static bool HasField(JsonElement settings, string key) =>
|
|
settings.GetProperty("sections").EnumerateArray()
|
|
.SelectMany(s => s.GetProperty("fields").EnumerateArray())
|
|
.Any(f => f.GetProperty("key").GetString() == key);
|
|
|
|
[Fact]
|
|
public async Task Anzeige_enthaelt_die_Freigabeliste_aber_keine_Geheimnisfelder()
|
|
{
|
|
await using var web = await WebUiTestHost.StartAsync();
|
|
WithSecrets().Save(web.SettingsPath);
|
|
|
|
var settings = await web.GetJsonAsync("/api/settings/server");
|
|
|
|
Assert.True(Field(settings, "VpnEnabled").GetProperty("editable").GetBoolean());
|
|
Assert.False(Field(settings, "WatchdogUrl").GetProperty("editable").GetBoolean());
|
|
Assert.False(Field(settings, "MullvadCliPath").GetProperty("editable").GetBoolean());
|
|
Assert.True(Field(settings, "ApplicationTimeZoneId").GetProperty("requiresRestart").GetBoolean());
|
|
Assert.False(Field(settings, "UsePolymarketWebsockets").GetProperty("requiresRestart").GetBoolean());
|
|
|
|
foreach (var secret in new[] { "LicenseKey", "WatchdogToken", "MullvadAccount", "PolygonRpcUrl" })
|
|
Assert.False(HasField(settings, secret), secret);
|
|
|
|
var secrets = settings.GetProperty("secrets").EnumerateArray()
|
|
.ToDictionary(x => x.GetProperty("label").GetString()!, x => x.GetProperty("status").GetString()!);
|
|
Assert.Equal("gesetzt", secrets["Mullvad-Account"]);
|
|
Assert.Equal("gesetzt (verschlüsselt)", secrets["Lizenzschlüssel"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Aendern_schreibt_nur_die_Werte_und_zieht_den_laufenden_Stand_nach()
|
|
{
|
|
var live = WithSecrets();
|
|
await using var web = await WebUiTestHost.StartAsync(s => s.AddSingleton(live));
|
|
WithSecrets().Save(web.SettingsPath);
|
|
|
|
var (status, body) = await web.PostJsonAsync("/api/settings/server", new
|
|
{
|
|
values = new { VpnEnabled = true, WatchdogIntervalSeconds = 120, VpnLocation = "cz-prg", Theme = "dunkel" }
|
|
});
|
|
|
|
Assert.Equal(200, status);
|
|
Assert.True(body.GetProperty("restartRequired").GetBoolean()); // Theme wirkt erst nach Neustart
|
|
|
|
var written = ServerSettings.Load(web.SettingsPath);
|
|
Assert.True(written.VpnEnabled);
|
|
Assert.Equal(120, written.WatchdogIntervalSeconds);
|
|
Assert.Equal("cz-prg", written.VpnLocation);
|
|
Assert.Equal(AppThemeVariant.Dunkel, written.Theme);
|
|
Assert.Equal("enc:v1:LIZENZ", written.LicenseKey);
|
|
Assert.Equal("enc:v1:WATCHDOG", written.WatchdogToken);
|
|
Assert.Equal("1234567890", written.MullvadAccount);
|
|
Assert.Equal("wss://polygon-mainnet.g.alchemy.com/v2/ALCHEMY", written.PolygonRpcUrl);
|
|
|
|
Assert.True(live.VpnEnabled); // laufender Stand
|
|
Assert.Equal(120, live.WatchdogIntervalSeconds);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("WatchdogUrl", "https://angreifer.example")]
|
|
[InlineData("MullvadCliPath", "/tmp/boese")]
|
|
[InlineData("LicenseKey", "neu")]
|
|
[InlineData("PolygonRpcUrl", "wss://x")]
|
|
public async Task Desktop_Felder_werden_mit_403_abgelehnt_und_nichts_geschrieben(string key, string value)
|
|
{
|
|
await using var web = await WebUiTestHost.StartAsync();
|
|
WithSecrets().Save(web.SettingsPath);
|
|
string before = File.ReadAllText(web.SettingsPath);
|
|
|
|
var values = new System.Collections.Generic.Dictionary<string, object> { [key] = value, ["VpnEnabled"] = true };
|
|
var (status, body) = await web.PostJsonAsync("/api/settings/server", new { values });
|
|
|
|
Assert.Equal(403, status);
|
|
Assert.Contains("Desktop", body.GetProperty("error").GetString());
|
|
Assert.Equal(before, File.ReadAllText(web.SettingsPath));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("VpnLocation", "cz account login 999")] // Argumente fuer das Mullvad-Programm einschleusen
|
|
[InlineData("VpnLocation", "cz;reboot")]
|
|
[InlineData("ApplicationTimeZoneId", "Mars/Olympus")]
|
|
[InlineData("WatchdogSource", "polytrader\\\" , \\\"x")]
|
|
public async Task Ungueltige_Texte_werden_abgelehnt(string key, string value)
|
|
{
|
|
await using var web = await WebUiTestHost.StartAsync();
|
|
WithSecrets().Save(web.SettingsPath);
|
|
string before = File.ReadAllText(web.SettingsPath);
|
|
|
|
var values = new System.Collections.Generic.Dictionary<string, object> { [key] = value };
|
|
var (status, _) = await web.PostJsonAsync("/api/settings/server", new { values });
|
|
|
|
Assert.Equal(400, status);
|
|
Assert.Equal(before, File.ReadAllText(web.SettingsPath));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ein_ungueltiger_Wert_verhindert_auch_die_gueltigen()
|
|
{
|
|
await using var web = await WebUiTestHost.StartAsync();
|
|
WithSecrets().Save(web.SettingsPath);
|
|
|
|
var (status, body) = await web.PostJsonAsync("/api/settings/server", new
|
|
{
|
|
values = new { VpnEnabled = true, WatchdogIntervalSeconds = 5 }
|
|
});
|
|
|
|
Assert.Equal(400, status);
|
|
Assert.Contains("Nichts gespeichert", body.GetProperty("error").GetString());
|
|
Assert.False(ServerSettings.Load(web.SettingsPath).VpnEnabled);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unbekanntes_Feld_leerer_Rumpf_und_gueltige_Zeitzone()
|
|
{
|
|
await using var web = await WebUiTestHost.StartAsync();
|
|
|
|
var (unknown, _) = await web.PostJsonAsync("/api/settings/server", new { values = new { GibtEsNicht = 1 } });
|
|
var (empty, _) = await web.PostJsonAsync("/api/settings/server");
|
|
Assert.Equal(400, unknown);
|
|
Assert.Equal(400, empty);
|
|
Assert.False(File.Exists(web.SettingsPath));
|
|
|
|
var (ok, _) = await web.PostJsonAsync("/api/settings/server", new { values = new { ApplicationTimeZoneId = "Europe/Berlin" } });
|
|
Assert.Equal(200, ok);
|
|
Assert.Equal("Europe/Berlin", ServerSettings.Load(web.SettingsPath).ApplicationTimeZoneId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unlesbare_Datei_wird_weder_angezeigt_noch_ueberschrieben()
|
|
{
|
|
await using var web = await WebUiTestHost.StartAsync();
|
|
const string kaputt = "<ServerSettings><LicenseKey>enc:v1:LIZENZ</LicenseKey><abgeschn";
|
|
File.WriteAllText(web.SettingsPath, kaputt);
|
|
|
|
using var get = await web.Client.GetAsync("/api/settings/server");
|
|
var (post, _) = await web.PostJsonAsync("/api/settings/server", new { values = new { VpnEnabled = true } });
|
|
|
|
Assert.Equal(500, (int)get.StatusCode);
|
|
Assert.Equal(500, post);
|
|
Assert.Equal(kaputt, File.ReadAllText(web.SettingsPath));
|
|
}
|
|
}
|
|
}
|