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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user