WebUI: Update-Status anzeigen, installiert wird im Desktop
Umsetzung von #565. GET /api/updates liefert die laufende Fassung (BuildInfo), die verfuegbare, den Hinweis auf ein Sicherheitsupdate und den Changelog. Geprueft wird mit demselben Aufruf wie beim Start des Desktops (UpdateClient gegen das Deploymentcenter). Den Lizenzschluessel fuer den Paketabruf liest der Server, er verlaesst ihn nicht. Zwischenspeicher: UpdateStatusService haelt das Ergebnis 30 Minuten vor. "Jetzt pruefen" umgeht das, aber fruehestens nach 60 Sekunden; bis dahin kommt der vorige Stand mit der Wartezeit zurueck. Gleichzeitige Aufrufe loesen eine einzige Pruefung aus. Ohne das fragte jeder Seitenaufruf, jedes offene Browserfenster, das Deploymentcenter. Die Pruefung liegt hinter IUpdateChecker und ist damit ohne Netz testbar. Kein Installieren ueber das WebUI, und ein Test haelt fest, dass es keine solche Route gibt: Die Installation startet den Update-Agent und beendet danach die Anwendung und mit ihr den Webserver. Richards Vorgabe beschraenkt das Beenden ueber das WebUI auf das Trading (#563). Oberflaeche: - Karte "Version & Updates" in den Einstellungen, mit "Jetzt pruefen". - Im Dashboard ein Banner wie im Desktop-Launcher, sobald ein Update vorliegt. Er verweist zur Installation auf den Desktop. Nicht durch Unit-Tests abgedeckt: DeploymentcenterUpdateChecker selbst, weil er ueber das Netz geht. Er wird auf der VM gegen das echte Deploymentcenter geprueft. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PolyTrader.Core;
|
||||
using PolyTrader.Web.Updates;
|
||||
|
||||
namespace PolyTrader.Web.Endpoints
|
||||
{
|
||||
/// <summary>
|
||||
/// Update-Status im WebUI (#565): laufende und verfügbare Fassung, Hinweis auf ein Sicherheitsupdate.
|
||||
///
|
||||
/// <para><b>Kein Installieren über das WebUI.</b> Die Installation startet den Update-Agent und beendet
|
||||
/// danach die Anwendung – und mit ihr den Webserver. Richards Vorgabe beschränkt das Beenden über das
|
||||
/// WebUI auf das Trading (#563). Installiert wird im Desktop.</para>
|
||||
/// </summary>
|
||||
public static class UpdatesEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapUpdatesEndpoints(this IEndpointRouteBuilder routes)
|
||||
{
|
||||
routes.MapGet("/api/updates", async (IServiceProvider sp, bool? refresh, CancellationToken ct) =>
|
||||
{
|
||||
var service = sp.GetService<UpdateStatusService>();
|
||||
if (service == null)
|
||||
return Results.Problem("Die Update-Prüfung ist nicht verfügbar.", statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
|
||||
var (snapshot, fromCache, retryAfter) = await service.GetAsync(refresh == true, ct);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
currentVersion = BuildInfo.Version,
|
||||
currentBuild = BuildInfo.Summary,
|
||||
enabled = snapshot.Enabled,
|
||||
updateAvailable = snapshot.UpdateAvailable,
|
||||
latestVersion = snapshot.LatestVersion,
|
||||
changelog = snapshot.Changelog,
|
||||
isCritical = snapshot.IsCritical,
|
||||
unauthorized = snapshot.Unauthorized,
|
||||
error = snapshot.Error,
|
||||
checkedAt = snapshot.CheckedAtUtc.ToString("o"),
|
||||
fromCache,
|
||||
retryAfterSeconds = retryAfter.HasValue ? (int?)Math.Ceiling(retryAfter.Value.TotalSeconds) : null,
|
||||
installVia = "desktop"
|
||||
});
|
||||
});
|
||||
|
||||
return routes;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using PolyTrader.Web.Updates;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
@@ -23,6 +24,10 @@ namespace PolyTrader.Web.Hosting
|
||||
hostBuilder.ConfigureServices((_, services) =>
|
||||
{
|
||||
services.AddSingleton(Options.Create(options));
|
||||
|
||||
// #565: Update-Status mit Zwischenspeicher - nicht jeder Seitenaufruf fragt das Deploymentcenter.
|
||||
services.AddSingleton<IUpdateChecker, DeploymentcenterUpdateChecker>();
|
||||
services.AddSingleton(sp => new UpdateStatusService(sp.GetRequiredService<IUpdateChecker>()));
|
||||
});
|
||||
|
||||
hostBuilder.ConfigureWebHostDefaults(webBuilder =>
|
||||
@@ -81,6 +86,7 @@ namespace PolyTrader.Web.Hosting
|
||||
endpoints.MapResolutionFarmingEndpoints();
|
||||
endpoints.MapSupervisorEndpoints();
|
||||
endpoints.MapAccountingEndpoints();
|
||||
endpoints.MapUpdatesEndpoints();
|
||||
|
||||
endpoints.MapGet("/login", async httpContext =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Deploymentcenter.Client;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PolyTrader.Core;
|
||||
using PolyTrader.Core.Configuration;
|
||||
using PolyTrader.Core.Security;
|
||||
using PolyTraderSharp.Models;
|
||||
|
||||
namespace PolyTrader.Web.Updates
|
||||
{
|
||||
/// <summary>Ergebnis einer Update-Prüfung, wie das WebUI es anzeigt (#565).</summary>
|
||||
public sealed record UpdateCheckSnapshot(
|
||||
DateTime CheckedAtUtc,
|
||||
bool Enabled,
|
||||
bool UpdateAvailable,
|
||||
bool Unauthorized,
|
||||
string? LatestVersion,
|
||||
string? Changelog,
|
||||
bool IsCritical,
|
||||
string? Error);
|
||||
|
||||
/// <summary>Fragt, ob es eine neuere Fassung gibt. Hinter einer Schnittstelle, damit sich die Zwischenspeicherung ohne Netz prüfen lässt.</summary>
|
||||
public interface IUpdateChecker
|
||||
{
|
||||
Task<UpdateCheckSnapshot> CheckAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hält das letzte Prüfergebnis vor (#565).
|
||||
///
|
||||
/// <para>Ohne diesen Zwischenspeicher fragte jeder Seitenaufruf des WebUI das Deploymentcenter – bei
|
||||
/// mehreren offenen Browserfenstern ein Mehrfaches dessen, was der Desktop mit einer Prüfung beim
|
||||
/// Start braucht. "Jetzt prüfen" umgeht den Zwischenspeicher, aber frühestens nach
|
||||
/// <see cref="MinManualInterval"/>; ein Doppelklick oder ein Skript in einer Schleife soll keine
|
||||
/// Anfrageflut auslösen.</para>
|
||||
/// </summary>
|
||||
public sealed class UpdateStatusService
|
||||
{
|
||||
public static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(30);
|
||||
public static readonly TimeSpan MinManualInterval = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly IUpdateChecker _checker;
|
||||
private readonly Func<DateTime> _utcNow;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private UpdateCheckSnapshot? _last;
|
||||
|
||||
public UpdateStatusService(IUpdateChecker checker, Func<DateTime>? utcNow = null)
|
||||
{
|
||||
_checker = checker;
|
||||
_utcNow = utcNow ?? (() => DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liefert das Ergebnis, bei Bedarf frisch geprüft. <c>RetryAfter</c> ist gesetzt, wenn eine erzwungene
|
||||
/// Prüfung zu früh kam und stattdessen der vorige Stand geliefert wird.
|
||||
/// </summary>
|
||||
public async Task<(UpdateCheckSnapshot Snapshot, bool FromCache, TimeSpan? RetryAfter)> GetAsync(bool force, CancellationToken ct)
|
||||
{
|
||||
// Eine Prüfung zur Zeit: Gleichzeitige Aufrufe warten und bekommen danach das frische Ergebnis.
|
||||
await _gate.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
var now = _utcNow();
|
||||
if (_last != null)
|
||||
{
|
||||
var age = now - _last.CheckedAtUtc;
|
||||
if (!force && age < CacheDuration)
|
||||
return (_last, true, null);
|
||||
if (force && age < MinManualInterval)
|
||||
return (_last, true, MinManualInterval - age);
|
||||
}
|
||||
|
||||
_last = await _checker.CheckAsync(ct);
|
||||
return (_last, false, null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft gegen das Deploymentcenter – derselbe Aufruf wie beim Start des Desktops (LauncherWindow).
|
||||
/// Der Lizenzschlüssel für den Paketabruf wird hier gelesen und verlässt den Server nicht.
|
||||
/// </summary>
|
||||
internal sealed class DeploymentcenterUpdateChecker : IUpdateChecker
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
public DeploymentcenterUpdateChecker(IServiceProvider services) => _services = services;
|
||||
|
||||
public async Task<UpdateCheckSnapshot> CheckAsync(CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var dc = _services.GetService<DeploymentcenterOptions>();
|
||||
if (dc == null || !dc.UpdateCheckEnabled || string.IsNullOrWhiteSpace(dc.BaseUrl))
|
||||
return new UpdateCheckSnapshot(now, false, false, false, null, null, false,
|
||||
"Die Update-Prüfung ist nicht eingerichtet (Deploymentcenter: UpdateCheckEnabled, BaseUrl).");
|
||||
|
||||
string? licenseKey = null;
|
||||
try
|
||||
{
|
||||
var settings = _services.GetService<ServerSettingsFile>()?.Read() ?? _services.GetService<ServerSettings>();
|
||||
licenseKey = SecretProtection.Unprotect(settings?.LicenseKey ?? string.Empty);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Nicht lesbarer Schlüssel: Die Prüfung läuft ohne Zugangsdaten weiter, wie im Desktop.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await new UpdateClient().CheckForUpdateAsync(
|
||||
baseUrl: dc.BaseUrl,
|
||||
projectId: dc.ProjectSlug,
|
||||
currentVersion: BuildInfo.Version,
|
||||
channel: dc.Channel,
|
||||
credentials: ReleaseCredentials.FromLicenseKey(licenseKey));
|
||||
|
||||
if (result.Unauthorized)
|
||||
return new UpdateCheckSnapshot(now, true, false, true, null, null, false,
|
||||
"Die Lizenz trägt nicht für den Paketabruf.");
|
||||
|
||||
bool available = result.UpdateAvailable && result.LatestRelease != null;
|
||||
return new UpdateCheckSnapshot(now, true, available, false,
|
||||
result.LatestRelease?.Version, result.LatestRelease?.Changelog, available && result.IsCritical, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UpdateCheckSnapshot(now, true, false, false, null, null, false,
|
||||
$"Deploymentcenter nicht erreichbar: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,8 @@
|
||||
<main class="app-main">
|
||||
<!-- VIEW: DASHBOARD -->
|
||||
<section id="view-dashboard" class="view-panel active">
|
||||
<!-- #565: wie das Update-Banner im Desktop-Launcher. Installation nur im Desktop. -->
|
||||
<div id="dashUpdateBanner" class="rate-warning" style="margin-bottom: 16px;" hidden></div>
|
||||
<div class="grid-4">
|
||||
<div class="card">
|
||||
<div class="card-title">Gesamt PnL</div>
|
||||
@@ -1204,6 +1206,20 @@ Wähle ein Profil und stelle mir eine Frage zu deinen Positionen, Risiken oder d
|
||||
<section id="view-settings" class="view-panel">
|
||||
<h2 style="margin-bottom: 16px; font-size: 18px;">Einstellungen</h2>
|
||||
|
||||
<!-- Version und Updates (#565). Installiert wird im Desktop: Die Installation beendet die
|
||||
Anwendung und damit auch das WebUI. -->
|
||||
<div class="card settings-form" style="margin-bottom: 16px;">
|
||||
<div class="card-title">
|
||||
<span>Version & Updates</span>
|
||||
<button class="btn btn-secondary btn-sm" onclick="loadUpdateStatus(true)">Jetzt prüfen</button>
|
||||
</div>
|
||||
<dl class="settings-readonly" id="updateStatusBody">
|
||||
<dt>Stand</dt>
|
||||
<dd>Lade …</dd>
|
||||
</dl>
|
||||
<div id="updateStatusMsg" class="form-desc" style="margin-top: 10px;" hidden></div>
|
||||
</div>
|
||||
|
||||
<!-- Zugang zum WebUI (#562): nur lesend. Port, Netzfreigabe und Token werden ausschliesslich
|
||||
im Desktop gepflegt. Wer das Token hat, soll sich damit weder den Zugang ins Netz oeffnen
|
||||
noch ihn verlegen oder sich selbst aussperren koennen. -->
|
||||
|
||||
@@ -6,6 +6,7 @@ let allLogs = [];
|
||||
|
||||
// Init on DOM ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadUpdateStatus(false); // #565: Banner im Dashboard; der Server haelt das Ergebnis vor
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && document.getElementById('modalStopTrading')?.style.display === 'flex') closeStopTradingModal();
|
||||
});
|
||||
@@ -813,6 +814,7 @@ async function copyLogViewer() {
|
||||
// Settings View
|
||||
async function loadSettings() {
|
||||
loadServerSettings();
|
||||
loadUpdateStatus(false);
|
||||
try {
|
||||
const res = await fetch('/api/settings/webui');
|
||||
if (!res.ok) return;
|
||||
@@ -829,6 +831,61 @@ async function loadSettings() {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ==================== Updates (#565) ====================
|
||||
|
||||
async function loadUpdateStatus(refresh) {
|
||||
const box = document.getElementById('updateStatusBody');
|
||||
try {
|
||||
const res = await fetch('/api/updates' + (refresh ? '?refresh=true' : ''));
|
||||
if (!res.ok) throw new Error(await errorText(res));
|
||||
renderUpdateStatus(await res.json(), refresh);
|
||||
} catch (e) {
|
||||
if (box) {
|
||||
box.innerHTML = '';
|
||||
appendDefinition(box, 'Stand', `Update-Status nicht abrufbar: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function appendDefinition(list, term, value) {
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = term;
|
||||
const dd = document.createElement('dd');
|
||||
dd.textContent = value;
|
||||
list.append(dt, dd);
|
||||
}
|
||||
|
||||
function renderUpdateStatus(u, refreshRequested) {
|
||||
let state;
|
||||
if (!u.enabled) state = u.error || 'Die Update-Prüfung ist nicht eingerichtet.';
|
||||
else if (u.error) state = u.error;
|
||||
else if (u.updateAvailable) state = `Update verfügbar: v${u.latestVersion}${u.isCritical ? ' – empfohlenes Sicherheitsupdate' : ''}`;
|
||||
else state = 'Diese Fassung ist aktuell.';
|
||||
|
||||
const box = document.getElementById('updateStatusBody');
|
||||
if (box) {
|
||||
box.innerHTML = '';
|
||||
appendDefinition(box, 'Laufende Fassung', u.currentBuild);
|
||||
appendDefinition(box, 'Stand', state);
|
||||
appendDefinition(box, 'Geprüft', new Date(u.checkedAt).toLocaleString('de-DE') + (u.fromCache ? ' (zwischengespeichert)' : ''));
|
||||
if (u.updateAvailable && u.changelog) appendDefinition(box, 'Änderungen', u.changelog);
|
||||
appendDefinition(box, 'Installation', 'Nur im Desktop – sie beendet die Anwendung und damit auch das WebUI.');
|
||||
}
|
||||
|
||||
const msg = document.getElementById('updateStatusMsg');
|
||||
if (msg) {
|
||||
msg.hidden = !(refreshRequested && u.retryAfterSeconds);
|
||||
if (!msg.hidden) msg.textContent = `Gerade erst geprüft – erneut möglich in ${u.retryAfterSeconds} s.`;
|
||||
}
|
||||
|
||||
const banner = document.getElementById('dashUpdateBanner');
|
||||
if (banner) {
|
||||
banner.hidden = !u.updateAvailable;
|
||||
if (u.updateAvailable)
|
||||
banner.textContent = `Update verfügbar: v${u.latestVersion}${u.isCritical ? ' (empfohlenes Sicherheitsupdate)' : ''} – Installation über den Desktop.`;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Server-Einstellungen (#561) ====================
|
||||
|
||||
let serverSettingsLoaded = {};
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PolyTrader.Web.Updates;
|
||||
using Xunit;
|
||||
|
||||
namespace PolyTrader.Tests.WebUi
|
||||
{
|
||||
/// <summary>
|
||||
/// Update-Status im WebUI (#565): Zwischenspeicher, Mindestabstand für "Jetzt prüfen", Anzeige.
|
||||
/// </summary>
|
||||
public class UpdateStatusTests
|
||||
{
|
||||
private sealed class FakeChecker : IUpdateChecker
|
||||
{
|
||||
private readonly Func<DateTime> _now;
|
||||
public int Calls;
|
||||
public UpdateCheckSnapshot Result { get; set; }
|
||||
|
||||
public FakeChecker(Func<DateTime> now)
|
||||
{
|
||||
_now = now;
|
||||
Result = new UpdateCheckSnapshot(DateTime.MinValue, true, true, false, "0.2.0", "Neu: Log-Viewer", true, null);
|
||||
}
|
||||
|
||||
public async Task<UpdateCheckSnapshot> CheckAsync(CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref Calls);
|
||||
await Task.Delay(20, ct);
|
||||
return Result with { CheckedAtUtc = _now() };
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Clock
|
||||
{
|
||||
public DateTime Now = new(2026, 9, 13, 12, 0, 0, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Innerhalb_der_Haltezeit_wird_nicht_erneut_gefragt()
|
||||
{
|
||||
var clock = new Clock();
|
||||
var checker = new FakeChecker(() => clock.Now);
|
||||
var service = new UpdateStatusService(checker, () => clock.Now);
|
||||
|
||||
var first = await service.GetAsync(force: false, CancellationToken.None);
|
||||
clock.Now = clock.Now.AddMinutes(29);
|
||||
var second = await service.GetAsync(force: false, CancellationToken.None);
|
||||
|
||||
Assert.False(first.FromCache);
|
||||
Assert.True(second.FromCache);
|
||||
Assert.Equal(1, checker.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Nach_Ablauf_der_Haltezeit_wird_frisch_gefragt()
|
||||
{
|
||||
var clock = new Clock();
|
||||
var checker = new FakeChecker(() => clock.Now);
|
||||
var service = new UpdateStatusService(checker, () => clock.Now);
|
||||
|
||||
await service.GetAsync(false, CancellationToken.None);
|
||||
clock.Now = clock.Now + UpdateStatusService.CacheDuration;
|
||||
var again = await service.GetAsync(false, CancellationToken.None);
|
||||
|
||||
Assert.False(again.FromCache);
|
||||
Assert.Equal(2, checker.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Jetzt_pruefen_zu_frueh_liefert_den_vorigen_Stand_mit_Wartezeit()
|
||||
{
|
||||
var clock = new Clock();
|
||||
var checker = new FakeChecker(() => clock.Now);
|
||||
var service = new UpdateStatusService(checker, () => clock.Now);
|
||||
|
||||
await service.GetAsync(false, CancellationToken.None);
|
||||
clock.Now = clock.Now.AddSeconds(20);
|
||||
var tooEarly = await service.GetAsync(force: true, CancellationToken.None);
|
||||
|
||||
Assert.True(tooEarly.FromCache);
|
||||
Assert.Equal(TimeSpan.FromSeconds(40), tooEarly.RetryAfter);
|
||||
Assert.Equal(1, checker.Calls);
|
||||
|
||||
clock.Now = clock.Now.AddSeconds(40);
|
||||
var allowed = await service.GetAsync(force: true, CancellationToken.None);
|
||||
|
||||
Assert.False(allowed.FromCache);
|
||||
Assert.Null(allowed.RetryAfter);
|
||||
Assert.Equal(2, checker.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gleichzeitige_Aufrufe_fragen_nur_einmal()
|
||||
{
|
||||
var clock = new Clock();
|
||||
var checker = new FakeChecker(() => clock.Now);
|
||||
var service = new UpdateStatusService(checker, () => clock.Now);
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 10).Select(_ => service.GetAsync(false, CancellationToken.None)));
|
||||
|
||||
Assert.Equal(1, checker.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Route_liefert_Fassung_Stand_und_verweist_zur_Installation_auf_den_Desktop()
|
||||
{
|
||||
var clock = new Clock();
|
||||
var checker = new FakeChecker(() => clock.Now);
|
||||
await using var web = await WebUiTestHost.StartAsync(s =>
|
||||
s.AddSingleton(new UpdateStatusService(checker, () => clock.Now)));
|
||||
|
||||
var status = await web.GetJsonAsync("/api/updates");
|
||||
|
||||
Assert.Equal(PolyTrader.Core.BuildInfo.Version, status.GetProperty("currentVersion").GetString());
|
||||
Assert.True(status.GetProperty("updateAvailable").GetBoolean());
|
||||
Assert.Equal("0.2.0", status.GetProperty("latestVersion").GetString());
|
||||
Assert.True(status.GetProperty("isCritical").GetBoolean());
|
||||
Assert.Equal("desktop", status.GetProperty("installVia").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ohne_Dienst_503()
|
||||
{
|
||||
await using var web = await WebUiTestHost.StartAsync();
|
||||
|
||||
using var res = await web.Client.GetAsync("/api/updates");
|
||||
|
||||
Assert.Equal(503, (int)res.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Das_WebUI_kennt_keinen_Weg_zum_Installieren()
|
||||
{
|
||||
await using var web = await WebUiTestHost.StartAsync();
|
||||
|
||||
var routes = web.Get<Microsoft.AspNetCore.Routing.EndpointDataSource>().Endpoints
|
||||
.OfType<Microsoft.AspNetCore.Routing.RouteEndpoint>()
|
||||
.Select(e => e.RoutePattern.RawText ?? string.Empty)
|
||||
.Where(r => r.Contains("update", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
Assert.Equal(new[] { "/api/updates" }, routes);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user