Module im WebUI aktivieren und deaktivieren

Umsetzung von #560.

Die Ansicht "Moduleinstellungen" zeigte bisher nur an, ob ein Modul laeuft. Jetzt
laesst sich dort wie im Modul-Tab des Desktops festlegen, welche Module beim
naechsten Start geladen werden. POST /api/modules/{name}/enabled nimmt den
gewuenschten Zustand ausdruecklich entgegen ({"enabled": true|false}) und
schaltet nicht einfach um. Zwei Klicks aus zwei Fenstern heben sich damit
nicht auf, und ein wiederholter Aufruf aendert nichts mehr.

Fehlerfaelle:
- Unbekanntes Modul: 404.
- Modul mit Aktivierungs-Blocker: 409.
- Fehlender Zustand: 400. Die Pruefung war zuerst lueckenhaft, der Test hat es
  gefunden: Mit "bool Enabled" machte ein leerer Rumpf {} daraus false, und ein
  unvollstaendiger Aufruf haette das Modul deaktiviert.

Gemeinsame Quelle statt Kopien:
- KnownModules: Die Modulliste stand viermal im Code (Program, CoreViews,
  ModuleViews, hartkodiert im WebUI). Der Start registriert jetzt die eine
  Liste.
- ModuleActivationView: Die Statusregeln des Desktops ("Aktiv - stoppt nach
  Neustart" usw.) liegen im Core. Desktop und WebUI zeigen dasselbe.

ServerSettingsFile - gefunden, bevor das WebUI in die Datei schreiben durfte:
ServerSettings.Load verschluckt jeden Lesefehler und liefert Vorgaben. "Frisch
lesen, ein Feld aendern, speichern" haette eine gesperrte oder beschaedigte
server_settings.xml durch Vorgaben ersetzt, und mit ihr Lizenzschluessel,
Watchdog-Token, Mullvad-Account und Alchemy-Key. Genau so schrieb auch der
Modul-Schalter des Desktops. ServerSettingsFile:
- liest streng und schreibt bei unlesbarer Datei nichts,
- schreibt atomar ueber eine Nachbardatei,
- erhaelt unter Linux die Dateirechte,
- serialisiert Aenderungen prozessweit.
Der Desktop-Schalter benutzt ihn jetzt ebenfalls.

Tests: Lizenzschluessel, Token und Alchemy-Key bleiben beim Umschalten in der
Datei; eine kaputte Datei bleibt Byte fuer Byte unveraendert und die Anzeige
bietet dann kein Umschalten an; Blocker, 404, 400, Idempotenz; die
Statustabelle; strenges Lesen, atomares Schreiben und Rechte am Dateiobjekt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-13 14:03:31 +02:00
co-authored by Claude Opus 5
parent 6498a3d0ec
commit 68d8d6f8d2
12 changed files with 630 additions and 88 deletions
@@ -0,0 +1,93 @@
using System;
using System.IO;
using System.Linq;
using PolyTrader.Core.Configuration;
using PolyTraderSharp.Models;
using Xunit;
namespace PolyTrader.Tests
{
/// <summary>
/// Fortschreiben der server_settings.xml zur Laufzeit (#560). Der Kern ist, was NICHT passieren darf:
/// dass eine unlesbare Datei still durch Vorgaben ersetzt wird und gespeicherte Schlüssel verschwinden.
/// </summary>
public sealed class ServerSettingsFileTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "polytrader-settings-" + Guid.NewGuid().ToString("N"));
private string SettingsPath => Path.Combine(_dir, "server_settings.xml");
public ServerSettingsFileTests() => Directory.CreateDirectory(_dir);
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { }
}
[Fact]
public void Fehlende_Datei_liefert_Vorgaben_und_wird_beim_Schreiben_angelegt()
{
var file = new ServerSettingsFile(SettingsPath);
Assert.Empty(file.Read().DisabledModules);
file.Update(s => s.DisabledModules.Add("Supervisor"));
Assert.Equal(new[] { "Supervisor" }, ServerSettings.Load(SettingsPath).DisabledModules);
}
[Fact]
public void Update_aendert_nur_das_eine_Feld()
{
new ServerSettings { LicenseKey = "enc:v1:L", WatchdogToken = "enc:v1:W", MullvadAccount = "1234", VpnLocation = "cz" }
.Save(SettingsPath);
new ServerSettingsFile(SettingsPath).Update(s => s.VpnLocation = "se");
var written = ServerSettings.Load(SettingsPath);
Assert.Equal("se", written.VpnLocation);
Assert.Equal("enc:v1:L", written.LicenseKey);
Assert.Equal("enc:v1:W", written.WatchdogToken);
Assert.Equal("1234", written.MullvadAccount);
}
[Fact]
public void Unlesbare_Datei_wird_nicht_ueberschrieben()
{
const string kaputt = "<ServerSettings><LicenseKey>enc:v1:L</LicenseKey><abgeschnitten";
File.WriteAllText(SettingsPath, kaputt);
var file = new ServerSettingsFile(SettingsPath);
Assert.Throws<InvalidDataException>(() => file.Read());
var ex = Assert.Throws<InvalidDataException>(() => file.Update(s => s.VpnLocation = "se"));
Assert.Contains("nicht lesbar", ex.Message);
Assert.Equal(kaputt, File.ReadAllText(SettingsPath));
// Zum Vergleich das Verhalten, vor dem das schützt: Load liefert still Vorgaben.
Assert.Equal(string.Empty, ServerSettings.Load(SettingsPath).LicenseKey);
}
[Fact]
public void Kein_Rest_einer_Zwischendatei_bleibt_liegen()
{
var file = new ServerSettingsFile(SettingsPath);
file.Update(s => s.VpnLocation = "se");
file.Update(s => s.VpnLocation = "de");
Assert.Equal(new[] { "server_settings.xml" }, Directory.GetFiles(_dir).Select(Path.GetFileName).ToArray());
}
[Fact]
public void Dateirechte_bleiben_unter_Linux_erhalten()
{
if (OperatingSystem.IsWindows()) return; // NTFS-Rechte vererben sich, dort ist nichts zu tun
new ServerSettings().Save(SettingsPath);
File.SetUnixFileMode(SettingsPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
new ServerSettingsFile(SettingsPath).Update(s => s.VpnLocation = "se");
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(SettingsPath));
}
}
}
@@ -0,0 +1,159 @@
using System;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using PolyTrader.Core.Configuration;
using PolyTrader.Core.Modularity;
using PolyTraderSharp.Models;
using Xunit;
namespace PolyTrader.Tests.WebUi
{
/// <summary>
/// Moduleinstellungen über das WebUI (#560): anzeigen, aktivieren, deaktivieren - und die
/// Einstellungsdatei dabei nie beschädigen.
/// </summary>
public class ModuleSettingsEndpointTests
{
private sealed class FakeModule : IPolyTraderModule
{
private readonly string? _blocker;
public FakeModule(string name, string? blocker = null) { Name = name; _blocker = blocker; }
public string Name { get; }
public string DbPrefix => "fake_";
public void RegisterServices(IServiceCollection services, IConfiguration configuration) { }
public void RegisterUi(IModuleUiHost host, IServiceProvider services) { }
public string? GetActivationBlocker(IConfiguration configuration) => _blocker;
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
private static JsonElement ByName(JsonElement modules, string name) =>
modules.EnumerateArray().Single(m => m.GetProperty("name").GetString() == name);
[Fact]
public async Task Laufendes_Modul_ohne_Eintrag_ist_aktiv_die_uebrigen_starten_nach_Neustart()
{
await using var web = await WebUiTestHost.StartAsync(s =>
s.AddSingleton<IPolyTraderModule>(new FakeModule("CopyTrading")));
var modules = await web.GetJsonAsync("/api/modules");
var ct = ByName(modules, "CopyTrading");
Assert.Equal("Aktiv", ct.GetProperty("status").GetString());
Assert.Equal("Deaktivieren", ct.GetProperty("action").GetString());
Assert.True(ct.GetProperty("canToggle").GetBoolean());
Assert.Equal("Startet nach Neustart", ByName(modules, "Supervisor").GetProperty("status").GetString());
}
[Fact]
public async Task Deaktivieren_schreibt_DisabledModules_und_laesst_Schluessel_in_der_Datei_stehen()
{
await using var web = await WebUiTestHost.StartAsync(s =>
s.AddSingleton<IPolyTraderModule>(new FakeModule("CopyTrading")));
new ServerSettings
{
LicenseKey = "enc:v1:LIZENZ",
WatchdogToken = "enc:v1:WATCHDOG",
PolygonRpcUrl = "wss://polygon-mainnet.g.alchemy.com/v2/ALCHEMY",
VpnLocation = "se"
}.Save(web.SettingsPath);
var (status, body) = await web.PostJsonAsync("/api/modules/copytrading/enabled", new { enabled = false });
Assert.Equal(200, status);
Assert.Equal("Aktiv – stoppt nach Neustart", body.GetProperty("status").GetString());
var written = ServerSettings.Load(web.SettingsPath);
Assert.Equal(new[] { "CopyTrading" }, written.DisabledModules); // kanonischer Name, nicht "copytrading"
Assert.Equal("enc:v1:LIZENZ", written.LicenseKey);
Assert.Equal("enc:v1:WATCHDOG", written.WatchdogToken);
Assert.Equal("wss://polygon-mainnet.g.alchemy.com/v2/ALCHEMY", written.PolygonRpcUrl);
Assert.Equal("se", written.VpnLocation);
}
[Fact]
public async Task Wiederholter_Aufruf_aendert_nichts_mehr_und_Aktivieren_nimmt_den_Eintrag_zurueck()
{
await using var web = await WebUiTestHost.StartAsync();
await web.PostJsonAsync("/api/modules/Supervisor/enabled", new { enabled = false });
await web.PostJsonAsync("/api/modules/Supervisor/enabled", new { enabled = false });
Assert.Equal(new[] { "Supervisor" }, ServerSettings.Load(web.SettingsPath).DisabledModules);
var (status, body) = await web.PostJsonAsync("/api/modules/Supervisor/enabled", new { enabled = true });
Assert.Equal(200, status);
Assert.Empty(ServerSettings.Load(web.SettingsPath).DisabledModules);
Assert.True(body.GetProperty("desiredEnabled").GetBoolean());
}
[Fact]
public async Task Unbekanntes_Modul_404_fehlender_Zustand_400()
{
await using var web = await WebUiTestHost.StartAsync();
var (unknown, _) = await web.PostJsonAsync("/api/modules/GibtEsNicht/enabled", new { enabled = false });
var (missing, _) = await web.PostJsonAsync("/api/modules/Supervisor/enabled");
Assert.Equal(404, unknown);
Assert.Equal(400, missing);
Assert.False(File.Exists(web.SettingsPath)); // nichts geschrieben
}
[Fact]
public async Task Modul_mit_Blocker_laesst_sich_nicht_schalten()
{
await using var web = await WebUiTestHost.StartAsync(s =>
s.AddSingleton(new KnownModules(new IPolyTraderModule[] { new FakeModule("Supervisor", blocker: "OpenRouter-Key fehlt") })));
var modules = await web.GetJsonAsync("/api/modules");
var sup = ByName(modules, "Supervisor");
Assert.False(sup.GetProperty("canToggle").GetBoolean());
Assert.Equal("OpenRouter-Key fehlt", sup.GetProperty("hint").GetString());
var (status, _) = await web.PostJsonAsync("/api/modules/Supervisor/enabled", new { enabled = true });
Assert.Equal(409, status);
}
[Fact]
public async Task Unlesbare_Einstellungsdatei_wird_nicht_angefasst()
{
await using var web = await WebUiTestHost.StartAsync();
const string kaputt = "<ServerSettings><LicenseKey>enc:v1:LIZENZ</LicenseKey><abgeschnitten";
File.WriteAllText(web.SettingsPath, kaputt);
var (status, body) = await web.PostJsonAsync("/api/modules/Supervisor/enabled", new { enabled = false });
Assert.Equal(500, status);
Assert.Contains("nicht lesbar", body.GetProperty("detail").GetString());
Assert.Equal(kaputt, File.ReadAllText(web.SettingsPath)); // Byte fuer Byte unveraendert
// Anzeigen geht weiter, umschalten wird nicht angeboten.
var modules = await web.GetJsonAsync("/api/modules");
Assert.All(modules.EnumerateArray(), m => Assert.False(m.GetProperty("canToggle").GetBoolean()));
}
// ---- ModuleActivationView ---------------------------------------------
[Theory]
[InlineData(true, true, null, "Aktiv", "Deaktivieren", true)]
[InlineData(true, false, null, "Aktiv – stoppt nach Neustart", "Aktivieren", true)]
[InlineData(false, true, null, "Startet nach Neustart", "Deaktivieren", true)]
[InlineData(false, false, null, "Deaktiviert", "Aktivieren", true)]
[InlineData(false, true, "API-Key fehlt", "⚠ Nicht aktivierbar", "—", false)]
public void Anzeige_folgt_den_Regeln_des_Desktops(bool running, bool desired, string? blocker,
string status, string action, bool canToggle)
{
var view = ModuleActivationView.Describe(new ModuleActivationInfo("X", running, blocker), desired);
Assert.Equal(status, view.Status);
Assert.Equal(action, view.Action);
Assert.Equal(canToggle, view.CanToggle);
}
}
}
+21 -2
View File
@@ -37,6 +37,7 @@ namespace PolyTrader.Tests.WebUi
public const string Token = "webui-test-token-7f3a9c1e5b2d4f6a8c0e";
private readonly IHost _host;
private readonly string _settingsDirectory;
public TradingState State { get; }
public CopyTradingState CopyState { get; }
@@ -44,6 +45,9 @@ namespace PolyTrader.Tests.WebUi
public JobManager Jobs { get; }
public WebUiOptions WebOptions { get; }
/// <summary>Eigene server_settings.xml je Testhost in einem temporaeren Ordner - nie die echte.</summary>
public string SettingsPath => System.IO.Path.Combine(_settingsDirectory, "server_settings.xml");
/// <summary>Mit gueltigem Token.</summary>
public HttpClient Client { get; }
@@ -51,9 +55,10 @@ namespace PolyTrader.Tests.WebUi
public HttpClient Anonymous { get; }
private WebUiTestHost(IHost host, TradingState state, CopyTradingState copyState,
TerminalLogger logger, JobManager jobs, WebUiOptions options)
TerminalLogger logger, JobManager jobs, WebUiOptions options, string settingsDirectory)
{
_host = host;
_settingsDirectory = settingsDirectory;
State = state;
CopyState = copyState;
Logger = logger;
@@ -82,6 +87,8 @@ namespace PolyTrader.Tests.WebUi
var jobs = new JobManager();
var options = new WebUiOptions { Enabled = true, AuthToken = Token };
configureOptions?.Invoke(options);
string settingsDirectory = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "polytrader-webui-" + Guid.NewGuid().ToString("N"));
System.IO.Directory.CreateDirectory(settingsDirectory);
var host = await new HostBuilder()
.ConfigureWebHost(web =>
@@ -95,13 +102,24 @@ namespace PolyTrader.Tests.WebUi
services.AddSingleton(copyState);
services.AddSingleton(logger);
services.AddSingleton(jobs);
// Wie im Betrieb (Program): alle Module und die Einstellungsdatei. Tests koennen beides
// in configureServices ersetzen - die letzte Registrierung gewinnt.
services.AddSingleton(new PolyTrader.Core.Modularity.KnownModules(new PolyTrader.Core.Modularity.IPolyTraderModule[]
{
new PolyTrader.Modules.CopyTrading.CopyTradingModule(),
new PolyTrader.Modules.ResolutionFarming.ResolutionFarmingModule(),
new PolyTrader.Modules.Supervisor.SupervisorModule(),
new PolyTrader.Modules.Accounting.AccountingModule()
}));
services.AddSingleton(new PolyTrader.Core.Configuration.ServerSettingsFile(
System.IO.Path.Combine(settingsDirectory, "server_settings.xml")));
configureServices?.Invoke(services);
});
web.Configure(app => app.UsePolyTraderWebPipeline());
})
.StartAsync();
return new WebUiTestHost(host, state, copyState, logger, jobs, options);
return new WebUiTestHost(host, state, copyState, logger, jobs, options, settingsDirectory);
}
/// <summary>CopyTrading-Repositories auf einer eigenen InMemory-Datenbank.</summary>
@@ -158,6 +176,7 @@ namespace PolyTrader.Tests.WebUi
Anonymous.Dispose();
await _host.StopAsync();
_host.Dispose();
try { System.IO.Directory.Delete(_settingsDirectory, recursive: true); } catch { /* temporaer, darf liegen bleiben */ }
}
}
}