up
Some checks failed
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Some checks failed
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.Caching;
|
||||
|
||||
/// <summary>
|
||||
/// Policy evaluation cache backed by <see cref="IDistributedCache{TValue}"/>.
|
||||
/// Supports any transport (InMemory, Valkey, PostgreSQL) via factory injection.
|
||||
/// </summary>
|
||||
public sealed class MessagingPolicyEvaluationCache : IPolicyEvaluationCache
|
||||
{
|
||||
private readonly IDistributedCache<PolicyEvaluationCacheEntry> _cache;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<MessagingPolicyEvaluationCache> _logger;
|
||||
private readonly TimeSpan _defaultTtl;
|
||||
|
||||
private long _totalRequests;
|
||||
private long _cacheHits;
|
||||
private long _cacheMisses;
|
||||
|
||||
public MessagingPolicyEvaluationCache(
|
||||
IDistributedCacheFactory cacheFactory,
|
||||
ILogger<MessagingPolicyEvaluationCache> logger,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<PolicyEngineOptions> options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cacheFactory);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
|
||||
var cacheOptions = options?.Value.EvaluationCache ?? new PolicyEvaluationCacheOptions();
|
||||
_defaultTtl = TimeSpan.FromMinutes(cacheOptions.DefaultTtlMinutes);
|
||||
|
||||
_cache = cacheFactory.Create<PolicyEvaluationCacheEntry>(new CacheOptions
|
||||
{
|
||||
KeyPrefix = "pe:",
|
||||
DefaultTtl = _defaultTtl,
|
||||
});
|
||||
|
||||
_logger.LogInformation(
|
||||
"Initialized MessagingPolicyEvaluationCache with provider {Provider}, TTL {Ttl}",
|
||||
_cache.ProviderName,
|
||||
_defaultTtl);
|
||||
}
|
||||
|
||||
public async Task<PolicyEvaluationCacheResult> GetAsync(
|
||||
PolicyEvaluationCacheKey key,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref _totalRequests);
|
||||
|
||||
var cacheKey = key.ToCacheKey();
|
||||
var result = await _cache.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.HasValue)
|
||||
{
|
||||
var entry = result.Value;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Check if entry is still valid
|
||||
if (entry.ExpiresAt > now)
|
||||
{
|
||||
Interlocked.Increment(ref _cacheHits);
|
||||
return new PolicyEvaluationCacheResult(entry, true, MapSource(_cache.ProviderName));
|
||||
}
|
||||
|
||||
// Entry expired - remove it
|
||||
await _cache.InvalidateAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _cacheMisses);
|
||||
return new PolicyEvaluationCacheResult(null, false, CacheSource.None);
|
||||
}
|
||||
|
||||
public async Task<PolicyEvaluationCacheBatch> GetBatchAsync(
|
||||
IReadOnlyList<PolicyEvaluationCacheKey> keys,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var found = new Dictionary<PolicyEvaluationCacheKey, PolicyEvaluationCacheEntry>();
|
||||
var notFound = new List<PolicyEvaluationCacheKey>();
|
||||
var hits = 0;
|
||||
var misses = 0;
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
var result = await GetAsync(key, cancellationToken).ConfigureAwait(false);
|
||||
if (result.Entry != null)
|
||||
{
|
||||
found[key] = result.Entry;
|
||||
hits++;
|
||||
}
|
||||
else
|
||||
{
|
||||
notFound.Add(key);
|
||||
misses++;
|
||||
}
|
||||
}
|
||||
|
||||
var source = MapSource(_cache.ProviderName);
|
||||
return new PolicyEvaluationCacheBatch
|
||||
{
|
||||
Found = found,
|
||||
NotFound = notFound,
|
||||
CacheHits = hits,
|
||||
CacheMisses = misses,
|
||||
InMemoryHits = source == CacheSource.InMemory ? hits : 0,
|
||||
RedisHits = source == CacheSource.Redis ? hits : 0,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task SetAsync(
|
||||
PolicyEvaluationCacheKey key,
|
||||
PolicyEvaluationCacheEntry entry,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = key.ToCacheKey();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var expiresAt = entry.ExpiresAt > now ? entry.ExpiresAt : now.Add(_defaultTtl);
|
||||
|
||||
var ttl = expiresAt - now;
|
||||
if (ttl <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = new CacheEntryOptions { TimeToLive = ttl };
|
||||
await _cache.SetAsync(cacheKey, entry, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task SetBatchAsync(
|
||||
IReadOnlyDictionary<PolicyEvaluationCacheKey, PolicyEvaluationCacheEntry> entries,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
foreach (var (key, entry) in entries)
|
||||
{
|
||||
var cacheKey = key.ToCacheKey();
|
||||
var expiresAt = entry.ExpiresAt > now ? entry.ExpiresAt : now.Add(_defaultTtl);
|
||||
|
||||
var ttl = expiresAt - now;
|
||||
if (ttl <= TimeSpan.Zero)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var options = new CacheEntryOptions { TimeToLive = ttl };
|
||||
await _cache.SetAsync(cacheKey, entry, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task InvalidateAsync(
|
||||
PolicyEvaluationCacheKey key,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = key.ToCacheKey();
|
||||
await _cache.InvalidateAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task InvalidateByPolicyDigestAsync(
|
||||
string policyDigest,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Pattern: pe:<policyDigest>:*
|
||||
var pattern = $"{policyDigest}:*";
|
||||
var count = await _cache.InvalidateByPatternAsync(pattern, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Invalidated {Count} cache entries for policy digest {Digest}",
|
||||
count,
|
||||
policyDigest);
|
||||
}
|
||||
|
||||
public PolicyEvaluationCacheStats GetStats()
|
||||
{
|
||||
var source = MapSource(_cache.ProviderName);
|
||||
var hits = Interlocked.Read(ref _cacheHits);
|
||||
|
||||
return new PolicyEvaluationCacheStats
|
||||
{
|
||||
TotalRequests = Interlocked.Read(ref _totalRequests),
|
||||
CacheHits = hits,
|
||||
CacheMisses = Interlocked.Read(ref _cacheMisses),
|
||||
InMemoryHits = source == CacheSource.InMemory ? hits : 0,
|
||||
RedisHits = source == CacheSource.Redis ? hits : 0,
|
||||
RedisFallbacks = 0,
|
||||
ItemCount = 0, // Not available from IDistributedCache
|
||||
EvictionCount = 0, // Not available from IDistributedCache
|
||||
};
|
||||
}
|
||||
|
||||
private static CacheSource MapSource(string providerName) => providerName.ToLowerInvariant() switch
|
||||
{
|
||||
"inmemory" => CacheSource.InMemory,
|
||||
"valkey" => CacheSource.Redis,
|
||||
"redis" => CacheSource.Redis,
|
||||
_ => CacheSource.None,
|
||||
};
|
||||
}
|
||||
@@ -29,8 +29,8 @@ public static class PolicyEngineServiceCollectionExtensions
|
||||
// Core compilation and evaluation services
|
||||
services.TryAddSingleton<PolicyCompilationService>();
|
||||
|
||||
// Cache
|
||||
services.TryAddSingleton<IPolicyEvaluationCache, InMemoryPolicyEvaluationCache>();
|
||||
// Cache - uses IDistributedCacheFactory for transport flexibility
|
||||
services.TryAddSingleton<IPolicyEvaluationCache, MessagingPolicyEvaluationCache>();
|
||||
|
||||
// Runtime evaluation
|
||||
services.TryAddSingleton<PolicyRuntimeEvaluationService>();
|
||||
|
||||
@@ -470,7 +470,7 @@ public sealed class PolicyGateEvaluator : IPolicyGateEvaluator
|
||||
Name = "LatticeState",
|
||||
Result = PolicyGateResultType.Warn,
|
||||
Reason = $"{latticeState} may indicate false positive for affected",
|
||||
Note = "Consider review: evidence suggests code may not be reachable"
|
||||
Note = "Consider review: evidence suggests code may not be reachable (possible false positive)"
|
||||
};
|
||||
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Messaging;
|
||||
using StellaOps.Messaging.Abstractions;
|
||||
using StellaOps.Policy.Engine.Options;
|
||||
|
||||
namespace StellaOps.Policy.Engine.ReachabilityFacts;
|
||||
|
||||
/// <summary>
|
||||
/// Reachability facts overlay cache backed by <see cref="IDistributedCache{TValue}"/>.
|
||||
/// Supports any transport (InMemory, Valkey, PostgreSQL) via factory injection.
|
||||
/// </summary>
|
||||
public sealed class MessagingReachabilityFactsOverlayCache : IReachabilityFactsOverlayCache
|
||||
{
|
||||
private readonly IDistributedCache<ReachabilityFact> _cache;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<MessagingReachabilityFactsOverlayCache> _logger;
|
||||
private readonly TimeSpan _defaultTtl;
|
||||
|
||||
private long _totalRequests;
|
||||
private long _cacheHits;
|
||||
private long _cacheMisses;
|
||||
|
||||
public MessagingReachabilityFactsOverlayCache(
|
||||
IDistributedCacheFactory cacheFactory,
|
||||
ILogger<MessagingReachabilityFactsOverlayCache> logger,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<PolicyEngineOptions> options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cacheFactory);
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
|
||||
var cacheOptions = options?.Value.ReachabilityCache ?? new ReachabilityFactsCacheOptions();
|
||||
_defaultTtl = TimeSpan.FromMinutes(cacheOptions.DefaultTtlMinutes);
|
||||
|
||||
_cache = cacheFactory.Create<ReachabilityFact>(new CacheOptions
|
||||
{
|
||||
KeyPrefix = "rf:",
|
||||
DefaultTtl = _defaultTtl,
|
||||
});
|
||||
|
||||
_logger.LogInformation(
|
||||
"Initialized MessagingReachabilityFactsOverlayCache with provider {Provider}, TTL {Ttl}",
|
||||
_cache.ProviderName,
|
||||
_defaultTtl);
|
||||
}
|
||||
|
||||
public async Task<(ReachabilityFact? Fact, bool CacheHit)> GetAsync(
|
||||
ReachabilityFactKey key,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Interlocked.Increment(ref _totalRequests);
|
||||
|
||||
var cacheKey = key.ToCacheKey();
|
||||
var result = await _cache.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.HasValue)
|
||||
{
|
||||
var fact = result.Value;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
// Check if entry is still valid
|
||||
if (!fact.ExpiresAt.HasValue || fact.ExpiresAt.Value > now)
|
||||
{
|
||||
Interlocked.Increment(ref _cacheHits);
|
||||
return (fact, true);
|
||||
}
|
||||
|
||||
// Entry expired - remove it
|
||||
await _cache.InvalidateAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _cacheMisses);
|
||||
return (null, false);
|
||||
}
|
||||
|
||||
public async Task<ReachabilityFactsBatch> GetBatchAsync(
|
||||
IReadOnlyList<ReachabilityFactKey> keys,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var found = new Dictionary<ReachabilityFactKey, ReachabilityFact>();
|
||||
var notFound = new List<ReachabilityFactKey>();
|
||||
var cacheHits = 0;
|
||||
var cacheMisses = 0;
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
var (fact, hit) = await GetAsync(key, cancellationToken).ConfigureAwait(false);
|
||||
if (fact != null)
|
||||
{
|
||||
found[key] = fact;
|
||||
cacheHits++;
|
||||
}
|
||||
else
|
||||
{
|
||||
notFound.Add(key);
|
||||
cacheMisses++;
|
||||
}
|
||||
}
|
||||
|
||||
return new ReachabilityFactsBatch
|
||||
{
|
||||
Found = found,
|
||||
NotFound = notFound,
|
||||
CacheHits = cacheHits,
|
||||
CacheMisses = cacheMisses,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task SetAsync(
|
||||
ReachabilityFactKey key,
|
||||
ReachabilityFact fact,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = key.ToCacheKey();
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var ttl = fact.ExpiresAt.HasValue && fact.ExpiresAt.Value > now
|
||||
? fact.ExpiresAt.Value - now
|
||||
: _defaultTtl;
|
||||
|
||||
if (ttl <= TimeSpan.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var options = new CacheEntryOptions { TimeToLive = ttl };
|
||||
await _cache.SetAsync(cacheKey, fact, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task SetBatchAsync(
|
||||
IReadOnlyDictionary<ReachabilityFactKey, ReachabilityFact> facts,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
foreach (var (key, fact) in facts)
|
||||
{
|
||||
var cacheKey = key.ToCacheKey();
|
||||
var ttl = fact.ExpiresAt.HasValue && fact.ExpiresAt.Value > now
|
||||
? fact.ExpiresAt.Value - now
|
||||
: _defaultTtl;
|
||||
|
||||
if (ttl <= TimeSpan.Zero)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var options = new CacheEntryOptions { TimeToLive = ttl };
|
||||
await _cache.SetAsync(cacheKey, fact, options, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task InvalidateAsync(ReachabilityFactKey key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var cacheKey = key.ToCacheKey();
|
||||
await _cache.InvalidateAsync(cacheKey, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task InvalidateTenantAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Pattern: rf:<tenantId>:*
|
||||
var pattern = $"{tenantId}:*";
|
||||
var count = await _cache.InvalidateByPatternAsync(pattern, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Invalidated {Count} cache entries for tenant {TenantId}", count, tenantId);
|
||||
}
|
||||
|
||||
public ReachabilityFactsCacheStats GetStats()
|
||||
{
|
||||
return new ReachabilityFactsCacheStats
|
||||
{
|
||||
TotalRequests = Interlocked.Read(ref _totalRequests),
|
||||
CacheHits = Interlocked.Read(ref _cacheHits),
|
||||
CacheMisses = Interlocked.Read(ref _cacheMisses),
|
||||
ItemCount = 0, // Not available from IDistributedCache
|
||||
EvictionCount = 0, // Not available from IDistributedCache
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,4 @@ This file mirrors sprint work for the Policy Engine module.
|
||||
|
||||
| Task ID | Sprint | Status | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `POLICY-GATE-401-033` | `docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md` | DOING | Gate `unreachable` reachability facts: missing evidence ref or low confidence => `under_investigation`; add tests and docs. |
|
||||
| `POLICY-GATE-401-033` | `docs/implplan/SPRINT_0401_0001_0001_reachability_evidence_chain.md` | DONE (2025-12-13) | Implemented PolicyGateEvaluator (lattice/uncertainty/evidence completeness) and aligned tests/docs; see `src/Policy/StellaOps.Policy.Engine/Gates/PolicyGateEvaluator.cs` and `src/Policy/__Tests/StellaOps.Policy.Engine.Tests/Gates/PolicyGateEvaluatorTests.cs`. |
|
||||
|
||||
@@ -55,13 +55,20 @@ public sealed partial class TenantContextMiddleware
|
||||
// Set tenant context for the request
|
||||
tenantContextAccessor.TenantContext = validationResult.Context;
|
||||
|
||||
using (_logger.BeginScope(new Dictionary<string, object?>
|
||||
try
|
||||
{
|
||||
["tenant_id"] = validationResult.Context?.TenantId,
|
||||
["project_id"] = validationResult.Context?.ProjectId
|
||||
}))
|
||||
using (_logger.BeginScope(new Dictionary<string, object?>
|
||||
{
|
||||
["tenant_id"] = validationResult.Context?.TenantId,
|
||||
["project_id"] = validationResult.Context?.ProjectId
|
||||
}))
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _next(context);
|
||||
tenantContextAccessor.TenantContext = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ using StellaOps.Policy.Storage.Postgres.Repositories;
|
||||
namespace StellaOps.Policy.Storage.Postgres.Migration;
|
||||
|
||||
/// <summary>
|
||||
/// Handles migration of policy data from MongoDB to PostgreSQL.
|
||||
/// Handles migration of policy data from legacy storage to PostgreSQL.
|
||||
/// Task references: PG-T4.9, PG-T4.10, PG-T4.11
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This migrator converts policy packs and their versions from MongoDB documents
|
||||
/// This migrator converts policy packs and their versions from legacy storage documents
|
||||
/// to PostgreSQL entities while preserving version history and active version settings.
|
||||
/// </remarks>
|
||||
public sealed class PolicyMigrator
|
||||
@@ -207,10 +207,10 @@ public sealed class PolicyMigrator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that migrated data matches between MongoDB and PostgreSQL.
|
||||
/// Verifies that migrated data matches expected counts in PostgreSQL.
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Tenant to verify.</param>
|
||||
/// <param name="expectedPacks">Expected pack count from MongoDB.</param>
|
||||
/// <param name="expectedPacks">Expected pack count from source data.</param>
|
||||
/// <param name="expectedVersions">Expected version counts per pack.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Verification result.</returns>
|
||||
@@ -314,7 +314,7 @@ public sealed class PolicyMigrator
|
||||
/// </summary>
|
||||
public sealed class PackMigrationData
|
||||
{
|
||||
/// <summary>Source system identifier (MongoDB _id).</summary>
|
||||
/// <summary>Source system identifier.</summary>
|
||||
public required string SourceId { get; init; }
|
||||
|
||||
/// <summary>Tenant identifier.</summary>
|
||||
|
||||
@@ -41,7 +41,7 @@ public sealed class TenantContextTests
|
||||
public void TenantContext_ForTenant_ThrowsOnNullTenantId()
|
||||
{
|
||||
// Act & Assert
|
||||
Assert.Throws<ArgumentException>(() => TenantContext.ForTenant(null!));
|
||||
Assert.Throws<ArgumentNullException>(() => TenantContext.ForTenant(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -156,9 +156,15 @@ public sealed class TenantContextMiddlewareTests
|
||||
public async Task Middleware_WithValidTenantHeader_SetsTenantContext()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? capturedContext = null;
|
||||
var nextCalled = false;
|
||||
var middleware = new TenantContextMiddleware(
|
||||
_ => { nextCalled = true; return Task.CompletedTask; },
|
||||
_ =>
|
||||
{
|
||||
nextCalled = true;
|
||||
capturedContext = _tenantAccessor.TenantContext;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
MsOptions.Options.Create(_options),
|
||||
_logger);
|
||||
|
||||
@@ -169,16 +175,22 @@ public sealed class TenantContextMiddlewareTests
|
||||
|
||||
// Assert
|
||||
Assert.True(nextCalled);
|
||||
Assert.NotNull(_tenantAccessor.TenantContext);
|
||||
Assert.Equal("tenant-123", _tenantAccessor.TenantContext.TenantId);
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.Equal("tenant-123", capturedContext!.TenantId);
|
||||
Assert.Null(_tenantAccessor.TenantContext);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Middleware_WithTenantAndProjectHeaders_SetsBothInContext()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? capturedContext = null;
|
||||
var middleware = new TenantContextMiddleware(
|
||||
_ => Task.CompletedTask,
|
||||
_ =>
|
||||
{
|
||||
capturedContext = _tenantAccessor.TenantContext;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
MsOptions.Options.Create(_options),
|
||||
_logger);
|
||||
|
||||
@@ -188,9 +200,10 @@ public sealed class TenantContextMiddlewareTests
|
||||
await middleware.InvokeAsync(context, _tenantAccessor);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(_tenantAccessor.TenantContext);
|
||||
Assert.Equal("tenant-123", _tenantAccessor.TenantContext.TenantId);
|
||||
Assert.Equal("project-456", _tenantAccessor.TenantContext.ProjectId);
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.Equal("tenant-123", capturedContext!.TenantId);
|
||||
Assert.Equal("project-456", capturedContext.ProjectId);
|
||||
Assert.Null(_tenantAccessor.TenantContext);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -218,6 +231,7 @@ public sealed class TenantContextMiddlewareTests
|
||||
public async Task Middleware_MissingTenantHeaderNotRequired_UsesDefaultTenant()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? capturedContext = null;
|
||||
var optionsNotRequired = new TenantContextOptions
|
||||
{
|
||||
Enabled = true,
|
||||
@@ -225,7 +239,11 @@ public sealed class TenantContextMiddlewareTests
|
||||
};
|
||||
|
||||
var middleware = new TenantContextMiddleware(
|
||||
_ => Task.CompletedTask,
|
||||
_ =>
|
||||
{
|
||||
capturedContext = _tenantAccessor.TenantContext;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
MsOptions.Options.Create(optionsNotRequired),
|
||||
_logger);
|
||||
|
||||
@@ -235,8 +253,9 @@ public sealed class TenantContextMiddlewareTests
|
||||
await middleware.InvokeAsync(context, _tenantAccessor);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(_tenantAccessor.TenantContext);
|
||||
Assert.Equal(TenantContextConstants.DefaultTenantId, _tenantAccessor.TenantContext.TenantId);
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.Equal(TenantContextConstants.DefaultTenantId, capturedContext!.TenantId);
|
||||
Assert.Null(_tenantAccessor.TenantContext);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -286,8 +305,13 @@ public sealed class TenantContextMiddlewareTests
|
||||
public async Task Middleware_ValidTenantIdFormat_Passes(string tenantId)
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? capturedContext = null;
|
||||
var middleware = new TenantContextMiddleware(
|
||||
_ => Task.CompletedTask,
|
||||
_ =>
|
||||
{
|
||||
capturedContext = _tenantAccessor.TenantContext;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
MsOptions.Options.Create(_options),
|
||||
_logger);
|
||||
|
||||
@@ -297,8 +321,9 @@ public sealed class TenantContextMiddlewareTests
|
||||
await middleware.InvokeAsync(context, _tenantAccessor);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(_tenantAccessor.TenantContext);
|
||||
Assert.Equal(tenantId, _tenantAccessor.TenantContext.TenantId);
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.Equal(tenantId, capturedContext!.TenantId);
|
||||
Assert.Null(_tenantAccessor.TenantContext);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -351,8 +376,13 @@ public sealed class TenantContextMiddlewareTests
|
||||
public async Task Middleware_ValidProjectIdFormat_Passes(string projectId)
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? capturedContext = null;
|
||||
var middleware = new TenantContextMiddleware(
|
||||
_ => Task.CompletedTask,
|
||||
_ =>
|
||||
{
|
||||
capturedContext = _tenantAccessor.TenantContext;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
MsOptions.Options.Create(_options),
|
||||
_logger);
|
||||
|
||||
@@ -362,16 +392,22 @@ public sealed class TenantContextMiddlewareTests
|
||||
await middleware.InvokeAsync(context, _tenantAccessor);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(_tenantAccessor.TenantContext);
|
||||
Assert.Equal(projectId, _tenantAccessor.TenantContext.ProjectId);
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.Equal(projectId, capturedContext!.ProjectId);
|
||||
Assert.Null(_tenantAccessor.TenantContext);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Middleware_WithWriteScope_SetsCanWriteTrue()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? capturedContext = null;
|
||||
var middleware = new TenantContextMiddleware(
|
||||
_ => Task.CompletedTask,
|
||||
_ =>
|
||||
{
|
||||
capturedContext = _tenantAccessor.TenantContext;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
MsOptions.Options.Create(_options),
|
||||
_logger);
|
||||
|
||||
@@ -387,16 +423,22 @@ public sealed class TenantContextMiddlewareTests
|
||||
await middleware.InvokeAsync(context, _tenantAccessor);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(_tenantAccessor.TenantContext);
|
||||
Assert.True(_tenantAccessor.TenantContext.CanWrite);
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.True(capturedContext!.CanWrite);
|
||||
Assert.Null(_tenantAccessor.TenantContext);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Middleware_WithoutWriteScope_SetsCanWriteFalse()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? capturedContext = null;
|
||||
var middleware = new TenantContextMiddleware(
|
||||
_ => Task.CompletedTask,
|
||||
_ =>
|
||||
{
|
||||
capturedContext = _tenantAccessor.TenantContext;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
MsOptions.Options.Create(_options),
|
||||
_logger);
|
||||
|
||||
@@ -412,16 +454,22 @@ public sealed class TenantContextMiddlewareTests
|
||||
await middleware.InvokeAsync(context, _tenantAccessor);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(_tenantAccessor.TenantContext);
|
||||
Assert.False(_tenantAccessor.TenantContext.CanWrite);
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.False(capturedContext!.CanWrite);
|
||||
Assert.Null(_tenantAccessor.TenantContext);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Middleware_ExtractsActorIdFromSubClaim()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? capturedContext = null;
|
||||
var middleware = new TenantContextMiddleware(
|
||||
_ => Task.CompletedTask,
|
||||
_ =>
|
||||
{
|
||||
capturedContext = _tenantAccessor.TenantContext;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
MsOptions.Options.Create(_options),
|
||||
_logger);
|
||||
|
||||
@@ -433,16 +481,22 @@ public sealed class TenantContextMiddlewareTests
|
||||
await middleware.InvokeAsync(context, _tenantAccessor);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(_tenantAccessor.TenantContext);
|
||||
Assert.Equal("user-id-123", _tenantAccessor.TenantContext.ActorId);
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.Equal("user-id-123", capturedContext!.ActorId);
|
||||
Assert.Null(_tenantAccessor.TenantContext);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Middleware_ExtractsActorIdFromHeader()
|
||||
{
|
||||
// Arrange
|
||||
TenantContext? capturedContext = null;
|
||||
var middleware = new TenantContextMiddleware(
|
||||
_ => Task.CompletedTask,
|
||||
_ =>
|
||||
{
|
||||
capturedContext = _tenantAccessor.TenantContext;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
MsOptions.Options.Create(_options),
|
||||
_logger);
|
||||
|
||||
@@ -453,8 +507,9 @@ public sealed class TenantContextMiddlewareTests
|
||||
await middleware.InvokeAsync(context, _tenantAccessor);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(_tenantAccessor.TenantContext);
|
||||
Assert.Equal("service-account-123", _tenantAccessor.TenantContext.ActorId);
|
||||
Assert.NotNull(capturedContext);
|
||||
Assert.Equal("service-account-123", capturedContext!.ActorId);
|
||||
Assert.Null(_tenantAccessor.TenantContext);
|
||||
}
|
||||
|
||||
private static DefaultHttpContext CreateHttpContext(
|
||||
|
||||
Reference in New Issue
Block a user