Paralleles WebGUI mit Kestrel, Token-Sicherung und SSE-Logs implementiert

This commit is contained in:
Richard
2026-09-07 21:55:51 +02:00
parent 28b3aedec7
commit 10f928eb4e
36 changed files with 3114 additions and 266 deletions
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -20,6 +20,11 @@
<ProjectReference Include="..\..\src\PolyTrader.Modules.ResolutionFarming\PolyTrader.Modules.ResolutionFarming.csproj" />
<ProjectReference Include="..\..\src\PolyTrader.Modules.Supervisor\PolyTrader.Modules.Supervisor.csproj" />
<ProjectReference Include="..\..\src\PolyTrader.Modules.Accounting\PolyTrader.Modules.Accounting.csproj" />
<ProjectReference Include="..\..\src\PolyTrader.Web\PolyTrader.Web.csproj" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
@@ -0,0 +1,178 @@
using System.IO;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using PolyTrader.Web.Configuration;
using PolyTrader.Web.Security;
using Xunit;
namespace PolyTrader.Tests.WebUi
{
public class TokenAuthMiddlewareTests
{
[Fact]
public void ValidateToken_CorrectToken_ReturnsTrue()
{
Assert.True(TokenAuthMiddleware.ValidateToken("secret-token-123", "secret-token-123"));
}
[Fact]
public void ValidateToken_WrongToken_ReturnsFalse()
{
Assert.False(TokenAuthMiddleware.ValidateToken("wrong-token", "secret-token-123"));
}
[Theory]
[InlineData("", "secret")]
[InlineData("secret", "")]
[InlineData(null, "secret")]
[InlineData("secret", null)]
public void ValidateToken_NullOrEmpty_ReturnsFalse(string? candidate, string? expected)
{
Assert.False(TokenAuthMiddleware.ValidateToken(candidate!, expected!));
}
[Fact]
public async Task InvokeAsync_UnconfiguredToken_RedirectsToLoginWithNoTokenError()
{
bool nextCalled = false;
var middleware = new TokenAuthMiddleware(_ =>
{
nextCalled = true;
return Task.CompletedTask;
});
var context = new DefaultHttpContext();
context.Request.Path = "/";
var options = Options.Create(new WebUiOptions { AuthToken = "" });
await middleware.InvokeAsync(context, options);
Assert.False(nextCalled);
Assert.Equal(StatusCodes.Status302Found, context.Response.StatusCode);
Assert.Equal("/login?error=no_token", context.Response.Headers.Location);
}
[Fact]
public async Task InvokeAsync_UnconfiguredToken_ApiReturns401()
{
bool nextCalled = false;
var middleware = new TokenAuthMiddleware(_ =>
{
nextCalled = true;
return Task.CompletedTask;
});
var context = new DefaultHttpContext();
context.Request.Path = "/api/dashboard/summary";
context.Response.Body = new MemoryStream();
var options = Options.Create(new WebUiOptions { AuthToken = "" });
await middleware.InvokeAsync(context, options);
Assert.False(nextCalled);
Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode);
}
[Fact]
public async Task InvokeAsync_PublicPathLogin_PassesThroughEvenWithoutToken()
{
bool nextCalled = false;
var middleware = new TokenAuthMiddleware(_ =>
{
nextCalled = true;
return Task.CompletedTask;
});
var context = new DefaultHttpContext();
context.Request.Path = "/login";
var options = Options.Create(new WebUiOptions { AuthToken = "" });
await middleware.InvokeAsync(context, options);
Assert.True(nextCalled);
}
[Fact]
public async Task InvokeAsync_ValidTokenInCookie_PassesThrough()
{
bool nextCalled = false;
var middleware = new TokenAuthMiddleware(_ =>
{
nextCalled = true;
return Task.CompletedTask;
});
var context = new DefaultHttpContext();
context.Request.Path = "/";
context.Request.Headers.Cookie = $"{TokenAuthMiddleware.CookieName}=valid-secret";
var options = Options.Create(new WebUiOptions { AuthToken = "valid-secret" });
await middleware.InvokeAsync(context, options);
Assert.True(nextCalled);
}
[Fact]
public async Task InvokeAsync_ValidTokenInBearerHeader_PassesThrough()
{
bool nextCalled = false;
var middleware = new TokenAuthMiddleware(_ =>
{
nextCalled = true;
return Task.CompletedTask;
});
var context = new DefaultHttpContext();
context.Request.Path = "/api/accounts";
context.Request.Headers.Authorization = "Bearer valid-secret";
var options = Options.Create(new WebUiOptions { AuthToken = "valid-secret" });
await middleware.InvokeAsync(context, options);
Assert.True(nextCalled);
}
[Fact]
public async Task InvokeAsync_MissingAuth_HtmlPageRedirectsToLogin()
{
bool nextCalled = false;
var middleware = new TokenAuthMiddleware(_ =>
{
nextCalled = true;
return Task.CompletedTask;
});
var context = new DefaultHttpContext();
context.Request.Path = "/jobs";
var options = Options.Create(new WebUiOptions { AuthToken = "valid-secret" });
await middleware.InvokeAsync(context, options);
Assert.False(nextCalled);
Assert.Equal(StatusCodes.Status302Found, context.Response.StatusCode);
Assert.Equal("/login", context.Response.Headers.Location);
}
[Fact]
public async Task InvokeAsync_MissingAuth_ApiReturns401()
{
bool nextCalled = false;
var middleware = new TokenAuthMiddleware(_ =>
{
nextCalled = true;
return Task.CompletedTask;
});
var context = new DefaultHttpContext();
context.Request.Path = "/api/jobs";
context.Response.Body = new MemoryStream();
var options = Options.Create(new WebUiOptions { AuthToken = "valid-secret" });
await middleware.InvokeAsync(context, options);
Assert.False(nextCalled);
Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode);
}
}
}
@@ -0,0 +1,89 @@
using System;
using System.IO;
using System.Text.Json.Nodes;
using PolyTrader.Web.Configuration;
using Xunit;
namespace PolyTrader.Tests.WebUi
{
public class WebUiConfigManagerTests : IDisposable
{
private readonly string _tempFile;
public WebUiConfigManagerTests()
{
_tempFile = Path.Combine(Path.GetTempPath(), $"appsettings_test_{Guid.NewGuid():N}.json");
}
public void Dispose()
{
if (File.Exists(_tempFile))
File.Delete(_tempFile);
}
[Fact]
public void GenerateSecureToken_Returns64CharHexAndIsRandom()
{
string t1 = WebUiConfigManager.GenerateSecureToken();
string t2 = WebUiConfigManager.GenerateSecureToken();
Assert.NotNull(t1);
Assert.Equal(64, t1.Length);
Assert.Matches("^[0-9a-f]{64}$", t1);
Assert.NotEqual(t1, t2);
}
[Fact]
public void SaveToLocalSettings_CreatesFileAndPreservesExistingSections()
{
// Vorhandene Sektion simulieren
File.WriteAllText(_tempFile, "{\"Database\":{\"Provider\":\"MySql\"}}");
var options = new WebUiOptions
{
Enabled = true,
Port = 5001,
LocalOnly = false,
AuthToken = "test-token-xyz"
};
WebUiConfigManager.SaveToLocalSettings(options, _tempFile);
string content = File.ReadAllText(_tempFile);
var node = JsonNode.Parse(content)?.AsObject();
Assert.NotNull(node);
Assert.Equal("MySql", node["Database"]?["Provider"]?.GetValue<string>());
var webNode = node["WebUi"]?.AsObject();
Assert.NotNull(webNode);
Assert.True(webNode["Enabled"]?.GetValue<bool>());
Assert.Equal(5001, webNode["Port"]?.GetValue<int>());
Assert.False(webNode["LocalOnly"]?.GetValue<bool>());
Assert.Equal("test-token-xyz", webNode["AuthToken"]?.GetValue<string>());
}
[Fact]
public void SaveToLocalSettings_WhenFileDoesNotExist_CreatesNewFile()
{
if (File.Exists(_tempFile)) File.Delete(_tempFile);
var options = new WebUiOptions
{
Enabled = true,
Port = 5050,
LocalOnly = true,
AuthToken = "brand-new-token"
};
WebUiConfigManager.SaveToLocalSettings(options, _tempFile);
Assert.True(File.Exists(_tempFile));
string content = File.ReadAllText(_tempFile);
var node = JsonNode.Parse(content)?.AsObject();
Assert.NotNull(node);
Assert.Equal(5050, node["WebUi"]?["Port"]?.GetValue<int>());
Assert.Equal("brand-new-token", node["WebUi"]?["AuthToken"]?.GetValue<string>());
}
}
}