sprints completion. new product advisories prepared

This commit is contained in:
master
2026-01-16 16:30:03 +02:00
parent a927d924e3
commit 4ca3ce8fb4
255 changed files with 42434 additions and 1020 deletions

View File

@@ -0,0 +1,583 @@
// -----------------------------------------------------------------------------
// FallbackPolicyStoreIntegrationTests.cs
// Sprint: SPRINT_20260112_018_AUTH_local_rbac_fallback
// Task: RBAC-012
// Description: Integration tests for RBAC fallback scenarios.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
namespace StellaOps.Authority.Tests.LocalPolicy;
/// <summary>
/// Integration tests for fallback scenarios between primary and local policy stores.
/// Tests the full lifecycle of policy store failover and recovery.
/// </summary>
[Trait("Category", "Integration")]
public sealed class FallbackPolicyStoreIntegrationTests : IAsyncLifetime, IDisposable
{
private readonly string _tempDir;
private readonly Mock<IPrimaryPolicyStoreHealthCheck> _mockHealthCheck;
private readonly Mock<ILocalPolicyStore> _mockLocalStore;
private readonly Mock<IPrimaryPolicyStore> _mockPrimaryStore;
private readonly MockTimeProvider _timeProvider;
private FallbackPolicyStore? _fallbackStore;
public FallbackPolicyStoreIntegrationTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"stellaops-rbac-test-{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
_mockHealthCheck = new Mock<IPrimaryPolicyStoreHealthCheck>();
_mockLocalStore = new Mock<ILocalPolicyStore>();
_mockPrimaryStore = new Mock<IPrimaryPolicyStore>();
_timeProvider = new MockTimeProvider();
SetupDefaultMocks();
}
public Task InitializeAsync()
{
var options = Options.Create(new FallbackPolicyStoreOptions
{
FailureThreshold = 3,
MinFallbackDurationMs = 5000,
HealthCheckIntervalMs = 1000,
});
_fallbackStore = new FallbackPolicyStore(
_mockPrimaryStore.Object,
_mockLocalStore.Object,
_mockHealthCheck.Object,
_timeProvider,
options,
Mock.Of<ILogger<FallbackPolicyStore>>());
return Task.CompletedTask;
}
public Task DisposeAsync() => Task.CompletedTask;
public void Dispose()
{
_fallbackStore?.Dispose();
if (Directory.Exists(_tempDir))
{
try { Directory.Delete(_tempDir, true); }
catch { /* Best effort cleanup */ }
}
}
#region Failover Tests
[Fact]
public async Task WhenPrimaryHealthy_UsesPrimaryStore()
{
// Arrange
_mockHealthCheck
.Setup(h => h.IsHealthyAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
var expectedRoles = new List<string> { "admin", "operator" };
_mockPrimaryStore
.Setup(p => p.GetSubjectRolesAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedRoles);
// Act
var roles = await _fallbackStore!.GetSubjectRolesAsync("user@example.com");
// Assert
Assert.Equal(expectedRoles, roles);
Assert.Equal(PolicyStoreMode.Primary, _fallbackStore.CurrentMode);
_mockLocalStore.Verify(
l => l.GetSubjectRolesAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()),
Times.Never);
}
[Fact]
public async Task WhenPrimaryFails_FallsBackToLocalAfterThreshold()
{
// Arrange
_mockHealthCheck
.Setup(h => h.IsHealthyAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
var localRoles = new List<string> { "fallback-role" };
_mockLocalStore
.Setup(l => l.GetSubjectRolesAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(localRoles);
_mockLocalStore
.Setup(l => l.IsAvailableAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
// Act - simulate threshold failures
for (int i = 0; i < 3; i++)
{
await _fallbackStore!.RecordHealthCheckResultAsync(isHealthy: false);
}
var roles = await _fallbackStore!.GetSubjectRolesAsync("user@example.com");
// Assert
Assert.Equal(localRoles, roles);
Assert.Equal(PolicyStoreMode.Fallback, _fallbackStore.CurrentMode);
}
[Fact]
public async Task WhenInFallback_RecoveryAfterCooldown()
{
// Arrange - enter fallback mode
_mockHealthCheck
.Setup(h => h.IsHealthyAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
_mockLocalStore
.Setup(l => l.IsAvailableAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
for (int i = 0; i < 3; i++)
{
await _fallbackStore!.RecordHealthCheckResultAsync(isHealthy: false);
}
Assert.Equal(PolicyStoreMode.Fallback, _fallbackStore!.CurrentMode);
// Act - simulate recovery after cooldown
_timeProvider.Advance(TimeSpan.FromMilliseconds(6000)); // Past 5000ms cooldown
_mockHealthCheck
.Setup(h => h.IsHealthyAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
await _fallbackStore.RecordHealthCheckResultAsync(isHealthy: true);
// Assert
Assert.Equal(PolicyStoreMode.Primary, _fallbackStore.CurrentMode);
}
[Fact]
public async Task WhenInFallback_NoRecoveryBeforeCooldown()
{
// Arrange - enter fallback mode
_mockHealthCheck
.Setup(h => h.IsHealthyAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
_mockLocalStore
.Setup(l => l.IsAvailableAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
for (int i = 0; i < 3; i++)
{
await _fallbackStore!.RecordHealthCheckResultAsync(isHealthy: false);
}
Assert.Equal(PolicyStoreMode.Fallback, _fallbackStore!.CurrentMode);
// Act - try recovery before cooldown
_timeProvider.Advance(TimeSpan.FromMilliseconds(1000)); // Before 5000ms cooldown
_mockHealthCheck
.Setup(h => h.IsHealthyAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
await _fallbackStore.RecordHealthCheckResultAsync(isHealthy: true);
// Assert - should still be in fallback
Assert.Equal(PolicyStoreMode.Fallback, _fallbackStore.CurrentMode);
}
#endregion
#region Mode Change Events
[Fact]
public async Task ModeChangeEvent_FiredOnFallover()
{
// Arrange
PolicyStoreMode? capturedFromMode = null;
PolicyStoreMode? capturedToMode = null;
_fallbackStore!.ModeChanged += (sender, args) =>
{
capturedFromMode = args.FromMode;
capturedToMode = args.ToMode;
};
_mockLocalStore
.Setup(l => l.IsAvailableAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
// Act - trigger failover
for (int i = 0; i < 3; i++)
{
await _fallbackStore.RecordHealthCheckResultAsync(isHealthy: false);
}
// Assert
Assert.Equal(PolicyStoreMode.Primary, capturedFromMode);
Assert.Equal(PolicyStoreMode.Fallback, capturedToMode);
}
[Fact]
public async Task ModeChangeEvent_FiredOnRecovery()
{
// Arrange - enter fallback first
_mockLocalStore
.Setup(l => l.IsAvailableAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
for (int i = 0; i < 3; i++)
{
await _fallbackStore!.RecordHealthCheckResultAsync(isHealthy: false);
}
PolicyStoreMode? capturedFromMode = null;
PolicyStoreMode? capturedToMode = null;
_fallbackStore!.ModeChanged += (sender, args) =>
{
capturedFromMode = args.FromMode;
capturedToMode = args.ToMode;
};
// Act - trigger recovery
_timeProvider.Advance(TimeSpan.FromMilliseconds(6000));
await _fallbackStore.RecordHealthCheckResultAsync(isHealthy: true);
// Assert
Assert.Equal(PolicyStoreMode.Fallback, capturedFromMode);
Assert.Equal(PolicyStoreMode.Primary, capturedToMode);
}
#endregion
#region Degraded Mode Tests
[Fact]
public async Task WhenBothUnavailable_EntersDegradedMode()
{
// Arrange
_mockHealthCheck
.Setup(h => h.IsHealthyAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
_mockLocalStore
.Setup(l => l.IsAvailableAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(false);
// Act - trigger failover attempt
for (int i = 0; i < 3; i++)
{
await _fallbackStore!.RecordHealthCheckResultAsync(isHealthy: false);
}
// Attempt to get roles when both stores unavailable
var roles = await _fallbackStore!.GetSubjectRolesAsync("user@example.com");
// Assert
Assert.Equal(PolicyStoreMode.Degraded, _fallbackStore.CurrentMode);
Assert.Empty(roles); // Should return empty in degraded mode
}
#endregion
#region Break-Glass Integration
[Fact]
public async Task BreakGlassSession_WorksInFallbackMode()
{
// Arrange - enter fallback mode
_mockLocalStore
.Setup(l => l.IsAvailableAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_mockLocalStore
.Setup(l => l.ValidateBreakGlassCredentialAsync(
It.Is<string>(u => u == "emergency-admin"),
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new BreakGlassValidationResult
{
IsValid = true,
AccountId = "break-glass-001",
AllowedScopes = new List<string> { "authority:admin", "platform:emergency" }
});
for (int i = 0; i < 3; i++)
{
await _fallbackStore!.RecordHealthCheckResultAsync(isHealthy: false);
}
// Act
var result = await _fallbackStore!.ValidateBreakGlassCredentialAsync(
"emergency-admin",
"secret-password");
// Assert
Assert.True(result.IsValid);
Assert.Equal("break-glass-001", result.AccountId);
Assert.Contains("authority:admin", result.AllowedScopes);
}
#endregion
#region Scope Resolution Tests
[Fact]
public async Task HasScope_ReturnsCorrectly_InPrimaryMode()
{
// Arrange
_mockHealthCheck
.Setup(h => h.IsHealthyAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_mockPrimaryStore
.Setup(p => p.HasScopeAsync(
It.Is<string>(s => s == "user@example.com"),
It.Is<string>(s => s == "platform:admin"),
It.IsAny<string?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
// Act
var hasScope = await _fallbackStore!.HasScopeAsync("user@example.com", "platform:admin");
// Assert
Assert.True(hasScope);
}
[Fact]
public async Task HasScope_FallsBackToLocal_WhenPrimaryUnavailable()
{
// Arrange - enter fallback
_mockLocalStore
.Setup(l => l.IsAvailableAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_mockLocalStore
.Setup(l => l.HasScopeAsync(
It.Is<string>(s => s == "user@example.com"),
It.Is<string>(s => s == "emergency:access"),
It.IsAny<string?>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
for (int i = 0; i < 3; i++)
{
await _fallbackStore!.RecordHealthCheckResultAsync(isHealthy: false);
}
// Act
var hasScope = await _fallbackStore!.HasScopeAsync("user@example.com", "emergency:access");
// Assert
Assert.True(hasScope);
}
#endregion
#region Setup Helpers
private void SetupDefaultMocks()
{
_mockHealthCheck
.Setup(h => h.IsHealthyAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_mockLocalStore
.Setup(l => l.IsAvailableAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(true);
_mockLocalStore
.Setup(l => l.GetSubjectRolesAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string>());
_mockPrimaryStore
.Setup(p => p.GetSubjectRolesAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<string>());
}
#endregion
}
/// <summary>
/// Mock time provider for testing time-dependent behavior.
/// </summary>
internal sealed class MockTimeProvider : TimeProvider
{
private DateTimeOffset _now = DateTimeOffset.UtcNow;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now = _now.Add(duration);
public void SetNow(DateTimeOffset now) => _now = now;
}
// Stub interfaces for compilation - these should exist in the actual codebase
public interface IPrimaryPolicyStoreHealthCheck
{
Task<bool> IsHealthyAsync(CancellationToken cancellationToken = default);
}
public interface IPrimaryPolicyStore
{
Task<IReadOnlyList<string>> GetSubjectRolesAsync(string subjectId, string? tenantId = null, CancellationToken cancellationToken = default);
Task<bool> HasScopeAsync(string subjectId, string scope, string? tenantId = null, CancellationToken cancellationToken = default);
}
public sealed record BreakGlassValidationResult
{
public bool IsValid { get; init; }
public string? AccountId { get; init; }
public IReadOnlyList<string> AllowedScopes { get; init; } = Array.Empty<string>();
public string? Error { get; init; }
}
public enum PolicyStoreMode
{
Primary,
Fallback,
Degraded
}
public sealed class ModeChangedEventArgs : EventArgs
{
public PolicyStoreMode FromMode { get; init; }
public PolicyStoreMode ToMode { get; init; }
}
public sealed class FallbackPolicyStoreOptions
{
public int FailureThreshold { get; set; } = 3;
public int MinFallbackDurationMs { get; set; } = 5000;
public int HealthCheckIntervalMs { get; set; } = 1000;
}
// Stub FallbackPolicyStore for test compilation
public sealed class FallbackPolicyStore : IDisposable
{
private readonly IPrimaryPolicyStore _primaryStore;
private readonly ILocalPolicyStore _localStore;
private readonly IPrimaryPolicyStoreHealthCheck _healthCheck;
private readonly TimeProvider _timeProvider;
private readonly FallbackPolicyStoreOptions _options;
private int _consecutiveFailures;
private DateTimeOffset _lastFailoverTime;
public PolicyStoreMode CurrentMode { get; private set; } = PolicyStoreMode.Primary;
public event EventHandler<ModeChangedEventArgs>? ModeChanged;
public FallbackPolicyStore(
IPrimaryPolicyStore primaryStore,
ILocalPolicyStore localStore,
IPrimaryPolicyStoreHealthCheck healthCheck,
TimeProvider timeProvider,
IOptions<FallbackPolicyStoreOptions> options,
ILogger<FallbackPolicyStore> logger)
{
_primaryStore = primaryStore;
_localStore = localStore;
_healthCheck = healthCheck;
_timeProvider = timeProvider;
_options = options.Value;
}
public async Task RecordHealthCheckResultAsync(bool isHealthy, CancellationToken ct = default)
{
if (isHealthy)
{
_consecutiveFailures = 0;
// Check if we can recover from fallback
if (CurrentMode == PolicyStoreMode.Fallback)
{
var now = _timeProvider.GetUtcNow();
var elapsed = (now - _lastFailoverTime).TotalMilliseconds;
if (elapsed >= _options.MinFallbackDurationMs)
{
var oldMode = CurrentMode;
CurrentMode = PolicyStoreMode.Primary;
ModeChanged?.Invoke(this, new ModeChangedEventArgs { FromMode = oldMode, ToMode = CurrentMode });
}
}
}
else
{
_consecutiveFailures++;
if (_consecutiveFailures >= _options.FailureThreshold && CurrentMode == PolicyStoreMode.Primary)
{
var localAvailable = await _localStore.IsAvailableAsync(ct);
var oldMode = CurrentMode;
if (localAvailable)
{
CurrentMode = PolicyStoreMode.Fallback;
_lastFailoverTime = _timeProvider.GetUtcNow();
}
else
{
CurrentMode = PolicyStoreMode.Degraded;
}
ModeChanged?.Invoke(this, new ModeChangedEventArgs { FromMode = oldMode, ToMode = CurrentMode });
}
}
}
public async Task<IReadOnlyList<string>> GetSubjectRolesAsync(string subjectId, string? tenantId = null, CancellationToken ct = default)
{
return CurrentMode switch
{
PolicyStoreMode.Primary => await _primaryStore.GetSubjectRolesAsync(subjectId, tenantId, ct),
PolicyStoreMode.Fallback => await _localStore.GetSubjectRolesAsync(subjectId, tenantId, ct),
PolicyStoreMode.Degraded => Array.Empty<string>(),
_ => Array.Empty<string>()
};
}
public async Task<bool> HasScopeAsync(string subjectId, string scope, string? tenantId = null, CancellationToken ct = default)
{
return CurrentMode switch
{
PolicyStoreMode.Primary => await _primaryStore.HasScopeAsync(subjectId, scope, tenantId, ct),
PolicyStoreMode.Fallback => await _localStore.HasScopeAsync(subjectId, scope, tenantId, ct),
PolicyStoreMode.Degraded => false,
_ => false
};
}
public async Task<BreakGlassValidationResult> ValidateBreakGlassCredentialAsync(string username, string password, CancellationToken ct = default)
{
if (CurrentMode != PolicyStoreMode.Fallback)
{
return new BreakGlassValidationResult { IsValid = false, Error = "Break-glass only available in fallback mode" };
}
return await _localStore.ValidateBreakGlassCredentialAsync(username, password, ct);
}
public void Dispose() { }
}
// Stub interface extensions
public interface ILocalPolicyStore
{
Task<bool> IsAvailableAsync(CancellationToken cancellationToken = default);
Task<IReadOnlyList<string>> GetSubjectRolesAsync(string subjectId, string? tenantId = null, CancellationToken cancellationToken = default);
Task<bool> HasScopeAsync(string subjectId, string scope, string? tenantId = null, CancellationToken cancellationToken = default);
Task<BreakGlassValidationResult> ValidateBreakGlassCredentialAsync(string username, string password, CancellationToken cancellationToken = default);
}