Merge branch 'fix/sec-33-34'
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.GalaxyRepository;
|
||||
using ZB.MOM.WW.MxGateway.Server;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Gateway-owned validation of the shared <see cref="GalaxyRepositoryOptions"/> (SEC-33): the Galaxy
|
||||
/// package binds the options but ships no validator, so the gateway rejects a persisted snapshot
|
||||
/// whose path is blank, invalid, or non-rooted on the running host, and supplies a rooted per-OS
|
||||
/// default when the shipped config leaves the path blank.
|
||||
/// </summary>
|
||||
public sealed class GalaxyRepositoryOptionsValidatorTests
|
||||
{
|
||||
/// <summary>Verifies a blank snapshot path with persistence enabled fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenPersistSnapshotAndPathBlank()
|
||||
{
|
||||
GalaxyRepositoryOptions options = new() { PersistSnapshot = true, SnapshotCachePath = "" };
|
||||
ValidateOptionsResult result = new GalaxyRepositoryOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(result.Failures!, f => f.Contains("MxGateway:Galaxy:SnapshotCachePath"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a non-rooted snapshot path with persistence enabled fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenPersistSnapshotAndPathNotRooted()
|
||||
{
|
||||
GalaxyRepositoryOptions options = new()
|
||||
{
|
||||
PersistSnapshot = true,
|
||||
SnapshotCachePath = "galaxy-snapshot.json",
|
||||
};
|
||||
ValidateOptionsResult result = new GalaxyRepositoryOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains("MxGateway:Galaxy:SnapshotCachePath") && f.Contains("rooted"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies a blank path passes when persistence is disabled (the path is never used).</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenPersistSnapshotDisabled()
|
||||
{
|
||||
GalaxyRepositoryOptions options = new() { PersistSnapshot = false, SnapshotCachePath = "" };
|
||||
ValidateOptionsResult result = new GalaxyRepositoryOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a valid, host-rooted snapshot path with persistence enabled passes.</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenPersistSnapshotAndPathRooted()
|
||||
{
|
||||
GalaxyRepositoryOptions options = new()
|
||||
{
|
||||
PersistSnapshot = true,
|
||||
SnapshotCachePath = Path.Combine(Path.GetTempPath(), "galaxy-snapshot.json"),
|
||||
};
|
||||
ValidateOptionsResult result = new GalaxyRepositoryOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the gateway supplies a rooted per-OS default when the shipped config leaves
|
||||
/// SnapshotCachePath blank, so the removed appsettings literal is not needed and validation
|
||||
/// passes on any host (SEC-33). Resolving the options triggers the registered validator.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Build_DefaultsBlankSnapshotCachePathToRootedDefault()
|
||||
{
|
||||
using WebApplication app = GatewayApplication.Build([]);
|
||||
|
||||
GalaxyRepositoryOptions options =
|
||||
app.Services.GetRequiredService<IOptions<GalaxyRepositoryOptions>>().Value;
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(options.SnapshotCachePath));
|
||||
Assert.True(Path.IsPathRooted(options.SnapshotCachePath));
|
||||
}
|
||||
}
|
||||
@@ -598,6 +598,61 @@ public sealed class GatewayOptionsValidatorTests
|
||||
f => f.Contains("MxGateway:Authentication:SqlitePath") && f.Contains("rooted"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies rooting is host-meaningful (SEC-33): a Windows drive-qualified literal fails on a
|
||||
/// Unix host (where it is not rooted) rather than being blessed and written as a junk-named
|
||||
/// relative file. On Windows the same literal is genuinely rooted and passes.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_SqlitePath_RootingIsHostMeaningful()
|
||||
{
|
||||
const string windowsLiteral = @"C:\ProgramData\MxGateway\gateway-auth.db";
|
||||
GatewayOptions options = CloneWithAuthentication(
|
||||
ValidOptions(),
|
||||
new AuthenticationOptions { SqlitePath = windowsLiteral });
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains("MxGateway:Authentication:SqlitePath") && f.Contains("rooted"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies MxGateway:Events:QueueCapacity above int.MaxValue/2 fails (GWC-24 rider): the value
|
||||
/// flows into WorkerClient as checked(2 * EventChannelCapacity), which would otherwise overflow.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenQueueCapacityExceedsUpperBound()
|
||||
{
|
||||
GatewayOptions options = CloneWithEvents(
|
||||
ValidOptions(),
|
||||
new EventOptions { QueueCapacity = (int.MaxValue / 2) + 1 });
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Failed);
|
||||
Assert.Contains(
|
||||
result.Failures!,
|
||||
f => f.Contains("MxGateway:Events:QueueCapacity"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies MxGateway:Events:QueueCapacity at exactly int.MaxValue/2 passes (boundary).</summary>
|
||||
[Fact]
|
||||
public void Validate_Succeeds_WhenQueueCapacityAtUpperBound()
|
||||
{
|
||||
GatewayOptions options = CloneWithEvents(
|
||||
ValidOptions(),
|
||||
new EventOptions { QueueCapacity = int.MaxValue / 2 });
|
||||
ValidateOptionsResult result = new GatewayOptionsValidator().Validate(null, options);
|
||||
Assert.True(result.Succeeded);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a non-rooted <see cref="TlsOptions.SelfSignedCertPath"/> fails validation.</summary>
|
||||
[Fact]
|
||||
public void Validate_Fails_WhenSelfSignedCertPathNotRooted()
|
||||
|
||||
@@ -80,7 +80,8 @@ public sealed class GatewayApplicationTests
|
||||
public async Task Build_MapsMetricsEndpoint()
|
||||
{
|
||||
// Bind an ephemeral port (:0) — xUnit runs test collections in parallel, so any
|
||||
// started-host test must avoid a fixed port to prevent a bind collision.
|
||||
// started-host test must avoid a fixed port to prevent a bind collision. The auth SQLite
|
||||
// store path is isolated to a per-process temp file by TestHostEnvironmentInitializer (SEC-33).
|
||||
await using WebApplication app = GatewayApplication.Build(["--urls=http://127.0.0.1:0"]);
|
||||
await app.StartAsync();
|
||||
try
|
||||
@@ -258,7 +259,9 @@ public sealed class GatewayApplicationTests
|
||||
string expectedFailure)
|
||||
{
|
||||
// Bind an ephemeral port (:0) — xUnit runs test collections in parallel, so any
|
||||
// WebApplication-building test must avoid a fixed port to prevent a bind collision.
|
||||
// WebApplication-building test must avoid a fixed port to prevent a bind collision. The auth
|
||||
// store path is isolated by TestHostEnvironmentInitializer (SEC-33), so startup opens a
|
||||
// writable store and the injected misconfiguration is what fails validation.
|
||||
await using WebApplication app = GatewayApplication.Build(
|
||||
[$"--{key}={value}", "--urls=http://127.0.0.1:0"]);
|
||||
|
||||
|
||||
@@ -97,6 +97,36 @@ public sealed class CachingApiKeyVerifierTests
|
||||
Assert.Equal(2, inner.CallCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SEC-34 window 3: an <see cref="IApiKeyCacheInvalidator.Invalidate"/> that lands while a
|
||||
/// verification is in flight in the inner library must discard that verification's repopulation,
|
||||
/// so the very next request re-verifies (revoke takes effect immediately, not after the TTL).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Invalidate_DuringInFlightVerification_DiscardsStaleRepopulation()
|
||||
{
|
||||
GatedVerifier inner = new(Success("operator01"));
|
||||
using MemoryCache cache = NewCache();
|
||||
CachingApiKeyVerifier verifier = new(inner, cache, TimeSpan.FromSeconds(30));
|
||||
|
||||
// Begin a verification and wait until it is parked inside the inner verifier.
|
||||
Task<ApiKeyVerification> inFlight = verifier.VerifyAsync(Header, CancellationToken.None);
|
||||
await inner.Entered;
|
||||
|
||||
// Revoke while the verify is still awaiting the inner library.
|
||||
((IApiKeyCacheInvalidator)verifier).Invalidate("operator01");
|
||||
|
||||
// Release the inner verifier; the in-flight call completes but must NOT cache its result.
|
||||
inner.Release();
|
||||
ApiKeyVerification result = await inFlight;
|
||||
Assert.True(result.Succeeded);
|
||||
|
||||
// The follow-up request finds no cached entry and reaches the inner verifier again.
|
||||
await verifier.VerifyAsync(Header, CancellationToken.None);
|
||||
Assert.Equal(2, inner.CallCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The store decorator coalesces repeated <c>MarkUsed</c> writes for the same key inside the
|
||||
/// window down to a single forwarded write — the ≤1/min guarantee for <c>last_used_utc</c>.
|
||||
@@ -196,6 +226,43 @@ public sealed class CachingApiKeyVerifierTests
|
||||
}
|
||||
}
|
||||
|
||||
// A verifier that parks inside VerifyAsync until Release() is called, so a test can interleave an
|
||||
// Invalidate with an in-flight verification.
|
||||
private sealed class GatedVerifier(ApiKeyVerification result) : IApiKeyVerifier
|
||||
{
|
||||
private readonly TaskCompletionSource _entered =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
private readonly TaskCompletionSource _release =
|
||||
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>Gets the number of times <see cref="VerifyAsync"/> has been called.</summary>
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
/// <summary>Completes once the first call has entered and parked in the inner verifier.</summary>
|
||||
public Task Entered => _entered.Task;
|
||||
|
||||
/// <summary>Unparks the first (gated) call.</summary>
|
||||
public void Release() => _release.TrySetResult();
|
||||
|
||||
/// <summary>Records the call; the first call parks on the gate, later calls return immediately.</summary>
|
||||
/// <param name="authorizationHeader">The authorization header presented by the caller.</param>
|
||||
/// <param name="ct">A token to observe for cancellation.</param>
|
||||
/// <returns>The fixed verification result.</returns>
|
||||
public async Task<ApiKeyVerification> VerifyAsync(string authorizationHeader, CancellationToken ct)
|
||||
{
|
||||
bool first = CallCount == 0;
|
||||
CallCount++;
|
||||
if (first)
|
||||
{
|
||||
_entered.TrySetResult();
|
||||
await _release.Task.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeStore : IApiKeyStore
|
||||
{
|
||||
/// <summary>Gets the number of times <see cref="MarkUsedAsync"/> has been called.</summary>
|
||||
|
||||
@@ -84,5 +84,19 @@ internal static class TestHostEnvironmentInitializer
|
||||
"secrets.db");
|
||||
Environment.SetEnvironmentVariable("Secrets__SqlitePath", secretsPath);
|
||||
}
|
||||
|
||||
// Starting the full host eagerly opens the auth SQLite store. Since SEC-33 the shipped
|
||||
// appsettings.json no longer carries an Authentication:SqlitePath, and the CommonApplicationData
|
||||
// code default resolves under an unwritable /usr/share on macOS. Point every host-building test at
|
||||
// a per-process temp store (same pattern as Secrets__SqlitePath above) so host-start tests are
|
||||
// auto-covered without a per-test override; a test that needs its own store still overrides this.
|
||||
if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("MxGateway__Authentication__SqlitePath")))
|
||||
{
|
||||
string authPath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"mxgw-tests-{Environment.ProcessId}",
|
||||
"gateway-auth.db");
|
||||
Environment.SetEnvironmentVariable("MxGateway__Authentication__SqlitePath", authPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user