using System.Security.Cryptography; using System.Text; using Microsoft.Extensions.DependencyInjection; using StackExchange.Redis; using Tiku.Application.Security; using Tiku.Domain.Identity; using Tiku.Domain.Tenancy; using Tiku.Infrastructure; using Tiku.Infrastructure.Security; namespace Tiku.IntegrationTests; public sealed class RedisSecurityStoreTests { [Fact] public async Task Unavailable_redis_fails_closed_for_security_operations() { await using var provider = BuildProvider( "localhost:6399,connectTimeout=200,syncTimeout=200,asyncTimeout=200,abortConnect=false", $"integration-unavailable-{Guid.NewGuid():N}"); var store = provider.GetRequiredService(); await Assert.ThrowsAsync(() => store.ConsumeAsync( [ new DistributedRateLimitBucket("password:ip:test", 1, TimeSpan.FromSeconds(1)) ])); Assert.False(await store.PingAsync()); } [Fact] public async Task Two_instances_share_atomic_limit_and_keys_contain_no_plaintext_identifier() { var connectionString = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS"); if (string.IsNullOrWhiteSpace(connectionString)) return; var environment = $"integration-{Guid.NewGuid():N}"; await using var first = BuildProvider(connectionString, environment); await using var second = BuildProvider(connectionString, environment); var store1 = first.GetRequiredService(); var store2 = second.GetRequiredService(); const string phone = "13812345678"; var phoneHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(phone))).ToLowerInvariant(); var bucket = new DistributedRateLimitBucket( $"sms-verify:tenant-id:login:{phoneHash}", 1, TimeSpan.FromMinutes(1)); try { var attempts = await Task.WhenAll(Enumerable.Range(0, 12).Select(index => (index & 1) == 0 ? store1.ConsumeAsync([bucket]) : store2.ConsumeAsync([bucket]))); Assert.Single(attempts, result => result.Allowed); Assert.All(attempts.Where(result => !result.Allowed), result => Assert.NotNull(result.RetryAfter)); var multiplexer = first.GetRequiredService(); var server = multiplexer.GetServer(multiplexer.GetEndPoints().Single()); var keys = server.Keys(pattern: $"tiku:{environment}:*").Select(key => key.ToString()).ToArray(); Assert.NotEmpty(keys); Assert.DoesNotContain(keys, key => key.Contains(phone, StringComparison.Ordinal)); } finally { var multiplexer = first.GetRequiredService(); var server = multiplexer.GetServer(multiplexer.GetEndPoints().Single()); var keys = server.Keys(pattern: $"tiku:{environment}:*").ToArray(); if (keys.Length > 0) await multiplexer.GetDatabase().KeyDeleteAsync(keys); } } [Fact] public async Task Authorization_state_is_shared_and_older_version_cannot_overwrite_newer_version() { var connectionString = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS"); if (string.IsNullOrWhiteSpace(connectionString)) return; var environment = $"integration-authz-{Guid.NewGuid():N}"; await using var first = BuildProvider(connectionString, environment); await using var second = BuildProvider(connectionString, environment); var writer = first.GetRequiredService(); var reader = second.GetRequiredService(); var tenantId = Guid.NewGuid(); var userId = Guid.NewGuid(); var sessionId = Guid.NewGuid(); var lookup = new AccessSecurityCacheLookup(sessionId, userId, AuthRealm.Tenant, tenantId); await writer.SetAsync(new AccessSecurityCacheState( new CachedSessionSecurityState(sessionId, userId, AuthRealm.Tenant, tenantId, "stamp", DateTimeOffset.UtcNow.AddMinutes(15), false), new CachedUserSecurityState(userId, UserStatus.Active, "stamp"), new CachedTenantSecurityState(tenantId, TenantStatus.Active), new CachedMembershipSecurityState(tenantId, userId, MembershipStatus.Active), null, new CachedAuthorizationVersion(AuthRealm.Tenant, tenantId, 1))); Assert.True((await reader.GetAsync(lookup))?.Complete); await writer.SetAuthorizationVersionAsync(AuthRealm.Tenant, tenantId, 5); await writer.SetAuthorizationVersionAsync(AuthRealm.Tenant, tenantId, 4); Assert.Equal(5, (await reader.GetAsync(lookup))?.AuthorizationVersion?.Version); } private static ServiceProvider BuildProvider(string connectionString, string environment) { var services = new ServiceCollection(); services.AddLogging(); services.Configure(_ => { }); services.AddRedisSecurity(connectionString, environment); return services.BuildServiceProvider(); } }