Files
PolyTraderSharp/tests/PolyTrader.Tests/WebUi/WebUiSecretsTests.cs
T
RichardandClaude Opus 5 99172a6be2 WebUI gibt keine Geheimnisse mehr heraus und aendert keine Zugangseinstellungen
Behebt #562.

Entscheidung Richard, 13.09.2026: Geheimnisse verwalten wir ausschliesslich im
Desktop.

Bis dahin lieferte GET /api/settings/webui das Zugangs-Token im Klartext. Ueber
das WebUI liessen sich ausserdem ein Token setzen, ein neues erzeugen und
LocalOnly abschalten. Wer das Token hatte, konnte das WebUI damit ueber
unverschluesseltes HTTP ins Netz oeffnen, sich den Zugang verlegen oder sich
selbst aussperren.

Jetzt:
- GET /api/settings/webui liefert Enabled, Port, LocalOnly und vom Token nur
  hasToken.
- POST /api/settings/webui und /webui/generate-token antworten mit 403 und
  protokollieren den Versuch. Sie bleiben als ausdrueckliche Ablehnung stehen:
  Ohne eigene Route faengt die Fallback-Route den POST ab und antwortet mit 200,
  ein altes Skript hielte das fuer einen Erfolg.
- Die WebUI-Einstellungsansicht zeigt den Zugang nur noch an. Gepflegt wird er
  im Desktop unter Einstellungen -> WebUI.

WebUiSecretsTests zaehlt alle GET-Routen selbst auf und prueft jede Antwort
gegen Kanarienwerte. Geprueft werden ApiKey, ApiSecret, ApiPassphrase und
PrivateKey eines Kontos, Mullvad-Account, Alchemy-Key, Watchdog-Token,
Lizenzschluessel und das WebUI-Token. Eine neue Route, die ein Konto oder die
Server-Einstellungen serialisiert, faellt damit auf, ohne dass jemand an diesen
Test denken muss. Gegen die alte Route schlug er fehl ("GET /api/settings/webui
liefert ein Geheimnis aus"), jetzt ist er gruen.

Ausserdem festgehalten: /api/accounts kennt nur GET. Polymarket-Konten bleiben
im WebUI lesbar und nicht aenderbar.

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

172 lines
7.3 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);
});
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));
}
}
}