Phase 4: Steuerendpunkte absichern, Kultur und Log-Pfad plattformfest

Die schreibenden API-Endpunkte waren ungeschuetzt. /api/capabilities meldete
zwar CanControl und AuthRequired aus der Konfiguration, erzwungen wurde davon
nichts: MapPredictalyticsControlEndpoints mappte Jobs, Trader-Steuerung und
Dev-Endpunkte bedingungslos.

Neu entscheidet die Bind-Adresse:
- localhost ohne Token: aktiv wie bisher, das Betriebssystem schuetzt
- localhost mit Token: aktiv, Token wird verlangt
- extern ohne Token: Endpunkte werden gar nicht gemappt, dazu ein Log.Error
- extern mit Token: Endpunkte verlangen X-Predictalytics-Key

Bewusst fail-safe herum, damit eine unbedachte Umstellung der Bind-Adresse
nicht stillschweigend die Steuerschnittstelle oeffnet. ApiTokenFilter
vergleicht laufzeitkonstant ueber CryptographicOperations.FixedTimeEquals.
Swagger ist bei externer Bindung abgeschaltet. /api/capabilities meldet jetzt
den tatsaechlichen Zustand statt einer Konfigurationsabsicht.

Weiter:
- RuntimeSetup.UseInvariantCulture als Erstes in beiden Startpfaden, damit
  die Systemlocale nicht auf Zahlen und Zeitstempel durchschlaegt
- Logverzeichnis neben den Einstellungen statt neben der Programmdatei,
  ueber PREDICTALYTICS_LOG_DIR uebersteuerbar
- CORS erlaubt immer die eigene Bind-URL, zusaetzliche Herkuenfte weiter
  ueber appsettings.json
- fehlendes Schwester-Repo Deploymentcenter scheitert mit verstaendlicher
  Meldung samt Klon-URL statt mit einem Fehler ueber eine fehlende csproj
- EgressPoolService: Linux-Verhalten von Socket.Bind kommentiert

24 neue Tests: IsPubliclyBound fuer Loopback-Schreibweisen, Leerwerte und
externe Adressen; dass leere Eingaben bestehende DB-Zugangsdaten nicht
ueberschreiben; und der Token-Filter gegen fehlende, falsche, zu kurze,
zu lange und abweichend geschriebene Token.

Build: 0 Fehler, 8 Warnungen (alle vorbestehend).
Tests: 124 bestanden, 0 Fehler, 1 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-08-08 21:13:49 +02:00
co-authored by Claude Opus 5
parent 0e01886640
commit b175df6d3e
15 changed files with 464 additions and 19 deletions
@@ -0,0 +1,64 @@
using Microsoft.AspNetCore.Http;
using Predictalytics.Api.Security;
namespace Predictalytics.Application.Tests.Hosting;
/// <summary>
/// Der Filter ist das Einzige, was die schreibenden Endpunkte schuetzt, sobald der
/// Dienst ueber das Netz erreichbar ist.
/// </summary>
public class ApiTokenFilterTests
{
private const string Token = "s3cr3t-token";
[Fact]
public async Task Correct_token_passes_through()
{
var (context, next, called) = Build(Token);
var result = await new ApiTokenFilter(Token).InvokeAsync(context, next);
Assert.True(called.Value);
Assert.Equal("weiter", result);
}
[Theory]
[InlineData("")] // Header fehlt
[InlineData("falsch")]
[InlineData("s3cr3t-toke")] // ein Zeichen zu kurz
[InlineData("s3cr3t-tokenX")] // ein Zeichen zu lang
[InlineData("S3CR3T-TOKEN")] // Schreibweise muss zaehlen
[InlineData(" s3cr3t-token")] // fuehrender Leerraum
public async Task Wrong_or_missing_token_is_rejected(string provided)
{
var (context, next, called) = Build(provided);
var result = await new ApiTokenFilter(Token).InvokeAsync(context, next);
Assert.False(called.Value);
Assert.NotNull(result);
Assert.NotEqual("weiter", result);
}
private static (DefaultEndpointFilterInvocationContext Context, EndpointFilterDelegate Next, StrongBox<bool> Called)
Build(string headerValue)
{
var httpContext = new DefaultHttpContext();
if (headerValue.Length > 0)
{
httpContext.Request.Headers[ApiTokenFilter.HeaderName] = headerValue;
}
var called = new StrongBox<bool>(false);
EndpointFilterDelegate next = _ =>
{
called.Value = true;
return ValueTask.FromResult<object?>("weiter");
};
return (new DefaultEndpointFilterInvocationContext(httpContext), next, called);
}
private sealed class StrongBox<T>(T value)
{
public T Value { get; set; } = value;
}
}
@@ -0,0 +1,85 @@
using Predictalytics.Hosting;
namespace Predictalytics.Application.Tests.Hosting;
/// <summary>
/// <see cref="PredictalyticsOptions.IsPubliclyBound"/> entscheidet, ob die schreibenden
/// API-Endpunkte ueberhaupt bereitgestellt werden und ob Swagger offen liegt. Ein falsches
/// Ergebnis ist deshalb kein Schoenheitsfehler, sondern eine offene Steuerschnittstelle.
/// </summary>
public class PredictalyticsOptionsTests
{
[Theory]
[InlineData("localhost")]
[InlineData("LOCALHOST")] // Schreibweise darf nicht entscheiden
[InlineData("LocalHost")]
[InlineData("127.0.0.1")]
[InlineData("::1")]
[InlineData(" localhost ")] // Leerraum aus dem Eingabefeld
[InlineData("")] // leer = Vorgabe = lokal
[InlineData(" ")]
public void IsPubliclyBound_is_false_for_loopback(string host)
{
var options = new PredictalyticsOptions { WebserverHost = host };
Assert.False(options.IsPubliclyBound);
}
[Theory]
[InlineData("0.0.0.0")]
[InlineData("::")]
[InlineData("192.168.178.10")]
[InlineData("10.0.0.5")]
[InlineData("predictalytics.example.com")]
public void IsPubliclyBound_is_true_for_everything_else(string host)
{
var options = new PredictalyticsOptions { WebserverHost = host };
Assert.True(options.IsPubliclyBound);
}
[Fact]
public void WebserverUrl_falls_back_to_localhost_when_host_is_blank()
{
var options = new PredictalyticsOptions { WebserverHost = " ", WebserverPort = 5000 };
Assert.Equal("http://localhost:5000", options.WebserverUrl);
}
[Fact]
public void WebserverUrl_combines_host_and_port()
{
var options = new PredictalyticsOptions { WebserverHost = "0.0.0.0", WebserverPort = 8080 };
Assert.Equal("http://0.0.0.0:8080", options.WebserverUrl);
}
[Fact]
public void ConnectionString_honours_the_configured_ssl_mode()
{
var options = new PredictalyticsOptions
{
DbServer = "db.example.com",
DbName = "predictalytics",
DbUser = "app",
DbPassword = "secret",
DbSslMode = MySqlConnector.MySqlSslMode.Preferred
};
var builder = new MySqlConnector.MySqlConnectionStringBuilder(options.ConnectionString);
Assert.Equal(MySqlConnector.MySqlSslMode.Preferred, builder.SslMode);
Assert.Equal("predictalytics", builder.Database);
}
/// <summary>
/// Die Setter ignorieren leere Werte bewusst — sonst wuerde ein versehentlich geleertes
/// Eingabefeld die hinterlegten Zugangsdaten verwerfen.
/// </summary>
[Fact]
public void Blank_database_values_do_not_overwrite_existing_ones()
{
var options = new PredictalyticsOptions { DbName = "predictalytics", DbUser = "app" };
options.DbName = "";
options.DbUser = " ";
Assert.Equal("predictalytics", options.DbName);
Assert.Equal("app", options.DbUser);
}
}
@@ -23,6 +23,7 @@
<ProjectReference Include="..\Predictalytics.Domain\Predictalytics.Domain.csproj" />
<ProjectReference Include="..\Predictalytics.Infrastructure\Predictalytics.Infrastructure.csproj" />
<ProjectReference Include="..\Predictalytics.Worker\Predictalytics.Worker.csproj" />
<ProjectReference Include="..\Predictalytics.Hosting\Predictalytics.Hosting.csproj" />
</ItemGroup>
</Project>