using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using System.Security.Claims; using Tiku.Application.Auth; using Tiku.Application.Security; using Tiku.Domain.Identity; using Tiku.Domain.Operations; using Tiku.Domain.Tenancy; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests.Api; public sealed class AuthSessionLifecycleTests { [Fact] public async Task Active_cache_falls_back_to_postgres_when_redis_is_unavailable() { await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary { ["ConnectionStrings:Redis"] = "localhost:6399,connectTimeout=200,syncTimeout=200,asyncTimeout=200,abortConnect=false", ["Security:AuthorizationCache:Mode"] = "Active" }); var seed = await SeedActiveMemberAsync(factory); var tokens = await IssueAsync(factory, seed); Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator)); using var scope = factory.CreateSystemScope("Validate PostgreSQL authorization fallback"); Assert.NotNull(await scope.ServiceProvider.GetRequiredService() .ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId)); } [Fact] public async Task Active_redis_cache_rejects_disabled_membership_on_the_next_validation() { var redis = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS"); if (string.IsNullOrWhiteSpace(redis)) { return; } var commandRecorder = new RecordingDbCommandInterceptor(); await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary { ["ConnectionStrings:Redis"] = redis, ["Security:AuthorizationCache:Mode"] = "Active" }, dbCommandInterceptor: commandRecorder); var seed = await SeedActiveMemberAsync(factory); var tokens = await IssueAsync(factory, seed); Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator)); using (var scope = factory.CreateSystemScope("Warm Redis access security state")) { Assert.NotNull(await scope.ServiceProvider.GetRequiredService() .ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId)); var cached = await scope.ServiceProvider.GetRequiredService() .GetAsync(new AccessSecurityCacheLookup(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId)); Assert.True(cached?.Complete); } commandRecorder.Reset(); using (var scope = factory.CreateSystemScope("Validate hot Redis access security state")) { Assert.NotNull(await scope.ServiceProvider.GetRequiredService() .ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId)); } Assert.Empty(commandRecorder.Snapshot()); using (var scope = factory.CreateSystemScope("Disable membership and invalidate Redis")) { var dbContext = scope.ServiceProvider.GetRequiredService(); var membership = await dbContext.TenantMemberships.SingleAsync(item => item.TenantId == seed.TenantId && item.UserId == seed.UserId); membership.Status = MembershipStatus.Disabled; await dbContext.SaveChangesAsync(); await scope.ServiceProvider.GetRequiredService() .InvalidateMembershipAsync(seed.TenantId, seed.UserId); } using (var scope = factory.CreateSystemScope("Validate disabled cached membership")) { Assert.Null(await scope.ServiceProvider.GetRequiredService() .ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId)); } } [Fact] public async Task Authorization_version_bypasses_stale_local_permission_snapshot_after_revocation() { var redis = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS"); if (string.IsNullOrWhiteSpace(redis)) { return; } await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary { ["ConnectionStrings:Redis"] = redis, ["Security:AuthorizationCache:Mode"] = "Active" }); var seed = await SeedActiveMemberAsync(factory); var roleId = Guid.NewGuid(); const string permissionCode = "tenant:auth-cache:manage"; await factory.SeedAsync(new BackendPermission { Code = permissionCode, Name = "Authorization cache test permission", Area = BackendPermissionArea.Tenant, PermissionModuleCode = PermissionModuleCatalog.ResolvePermissionModuleCode(BackendPermissions.TenantSettingsManage) }); using (var scope = factory.CreateSystemScope("Seed versioned tenant permission")) { var dbContext = scope.ServiceProvider.GetRequiredService(); dbContext.AuthorizationScopeVersions.Add(new AuthorizationScopeVersion { Realm = AuthRealm.Tenant, TenantId = seed.TenantId }); await dbContext.SaveChangesAsync(); dbContext.TenantBackendRoles.Add(new TenantBackendRole { Id = roleId, TenantId = seed.TenantId, Code = "versioned-cache-role", Name = "Versioned cache role" }); dbContext.TenantBackendRolePermissions.Add(new TenantBackendRolePermission { TenantId = seed.TenantId, RoleId = roleId, PermissionCode = permissionCode }); dbContext.TenantBackendUserRoles.Add(new TenantBackendUserRole { TenantId = seed.TenantId, UserId = seed.UserId, RoleId = roleId }); await dbContext.SaveChangesAsync(); } var tokens = await IssueAsync(factory, seed); Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator)); AuthSessionValidationResult validated; using (var scope = factory.CreateSystemScope("Warm session state for permission snapshot")) { validated = Assert.IsType(await scope.ServiceProvider .GetRequiredService() .ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId)); } Assert.True((await LoadAccessAsync(factory, seed, validated)).HasTenantPermission(permissionCode)); using (var scope = factory.CreateSystemScope("Revoke permission and publish durable version")) { var dbContext = scope.ServiceProvider.GetRequiredService(); var binding = await dbContext.TenantBackendRolePermissions.SingleAsync(item => item.TenantId == seed.TenantId && item.RoleId == roleId && item.PermissionCode == permissionCode); dbContext.Remove(binding); await dbContext.SaveChangesAsync(); await scope.ServiceProvider.GetRequiredService() .ProcessPendingAsync(); } using (var scope = factory.CreateSystemScope("Reload session authorization version")) { validated = Assert.IsType(await scope.ServiceProvider .GetRequiredService() .ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId)); } Assert.False((await LoadAccessAsync(factory, seed, validated)).HasTenantPermission(permissionCode)); } private static async Task LoadAccessAsync( ApiTestFactory factory, SessionSeed seed, AuthSessionValidationResult validated) { using var scope = factory.Services.CreateScope(); scope.ServiceProvider.GetRequiredService() .Initialize(seed.TenantId, null, TenantResolutionSource.Jwt); ((CurrentUser)scope.ServiceProvider.GetRequiredService()).Load(new ClaimsPrincipal( new ClaimsIdentity([new Claim(TikuClaimTypes.UserId, seed.UserId.ToString())], "integration"))); scope.ServiceProvider.GetRequiredService().SetValidatedSession(validated); return await scope.ServiceProvider.GetRequiredService().GetAsync(); } [Fact] public async Task Refresh_rotation_creates_a_child_and_replay_revokes_the_entire_family() { await using var factory = new ApiTestFactory(); var seed = await SeedActiveMemberAsync(factory); var original = await IssueAsync(factory, seed); AuthTokenPair rotated; using (var scope = factory.CreateSystemScope("Rotate refresh token")) { rotated = await scope.ServiceProvider.GetRequiredService() .RotateAsync(original.RefreshToken, "127.0.0.1", "integration-test"); } Assert.True(TryLocate(factory, original.RefreshToken, out var originalLocator)); Assert.True(TryLocate(factory, rotated.RefreshToken, out var rotatedLocator)); using (var scope = factory.CreateSystemScope("Verify rotated session lineage")) { var dbContext = scope.ServiceProvider.GetRequiredService(); var sessions = await dbContext.AuthSessions .Where(session => session.Id == originalLocator.SessionId || session.Id == rotatedLocator.SessionId) .OrderBy(session => session.ParentSessionId == null ? 0 : 1) .ToListAsync(); Assert.Equal(2, sessions.Count); Assert.Equal(originalLocator.SessionId, sessions[0].Id); Assert.Equal(rotatedLocator.SessionId, sessions[0].ReplacedBySessionId); Assert.Equal("rotated", sessions[0].RevokedReason); Assert.Equal(originalLocator.SessionId, sessions[1].ParentSessionId); Assert.Equal(sessions[0].TokenFamilyId, sessions[1].TokenFamilyId); } using (var scope = factory.CreateSystemScope("Replay rotated refresh token")) { await Assert.ThrowsAsync(() => scope.ServiceProvider.GetRequiredService() .RotateAsync(original.RefreshToken, null, null)); } using (var scope = factory.CreateSystemScope("Verify refresh family revocation")) { var store = scope.ServiceProvider.GetRequiredService(); var validation = await store.ValidateAccessSessionAsync( rotatedLocator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId); Assert.Null(validation); var dbContext = scope.ServiceProvider.GetRequiredService(); var family = await dbContext.AuthSessions .Where(session => session.TokenFamilyId == originalLocator.SessionId) .ToListAsync(); Assert.All(family, session => Assert.NotNull(session.RevokedAt)); Assert.Contains(family, session => session.RevokedReason == "refresh_token_reuse"); } } [Fact] public async Task Concurrent_refresh_allows_only_one_rotation_and_revokes_the_replayed_family() { await using var factory = new ApiTestFactory(); var seed = await SeedActiveMemberAsync(factory); var original = await IssueAsync(factory, seed); Assert.True(TryLocate(factory, original.RefreshToken, out var originalLocator)); using var firstScope = factory.CreateSystemScope("First concurrent refresh"); using var secondScope = factory.CreateSystemScope("Second concurrent refresh"); var first = TryRotateAsync( firstScope.ServiceProvider.GetRequiredService(), original.RefreshToken); var second = TryRotateAsync( secondScope.ServiceProvider.GetRequiredService(), original.RefreshToken); var results = await Task.WhenAll(first, second); Assert.Single(results, result => result is not null); Assert.Single(results, result => result is null); using var verificationScope = factory.CreateSystemScope("Verify concurrent refresh family"); var dbContext = verificationScope.ServiceProvider.GetRequiredService(); var family = await dbContext.AuthSessions .Where(session => session.TokenFamilyId == originalLocator.SessionId) .ToListAsync(); Assert.Equal(2, family.Count); Assert.All(family, session => Assert.NotNull(session.RevokedAt)); Assert.Contains(family, session => session.RevokedReason == "refresh_token_reuse"); } [Fact] public async Task Access_session_fails_immediately_after_membership_is_disabled() { await using var factory = new ApiTestFactory(); var seed = await SeedActiveMemberAsync(factory); var tokens = await IssueAsync(factory, seed); Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator)); using (var scope = factory.CreateSystemScope("Disable tenant membership")) { var dbContext = scope.ServiceProvider.GetRequiredService(); var membership = await dbContext.TenantMemberships.SingleAsync(item => item.TenantId == seed.TenantId && item.UserId == seed.UserId); membership.Status = MembershipStatus.Disabled; await dbContext.SaveChangesAsync(); } using (var scope = factory.CreateSystemScope("Validate disabled membership session")) { var validation = await scope.ServiceProvider.GetRequiredService() .ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId); Assert.Null(validation); } } [Fact] public async Task Access_session_fails_immediately_after_security_stamp_changes() { await using var factory = new ApiTestFactory(); var seed = await SeedActiveMemberAsync(factory); var tokens = await IssueAsync(factory, seed); Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator)); using (var scope = factory.CreateSystemScope("Change user security stamp")) { var dbContext = scope.ServiceProvider.GetRequiredService(); var user = await dbContext.Users.SingleAsync(item => item.Id == seed.UserId); user.SecurityStamp = Guid.NewGuid().ToString("N"); await dbContext.SaveChangesAsync(); } using (var scope = factory.CreateSystemScope("Validate stale security stamp session")) { var validation = await scope.ServiceProvider.GetRequiredService() .ValidateAccessSessionAsync(locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId); Assert.Null(validation); } } [Fact] public async Task Tenant_session_remains_valid_after_a_backend_permission_is_revoked() { await using var factory = new ApiTestFactory(); var seed = await SeedActiveMemberAsync(factory); var role = new TenantBackendRole { TenantId = seed.TenantId, Code = "session-test-admin", Name = "Session test administrator" }; const string permissionCode = "tenant:session-test:manage"; await factory.SeedAsync( new BackendPermission { Code = permissionCode, Name = permissionCode, Area = BackendPermissionArea.Tenant, PermissionModuleCode = "tenant_dashboard" }, role, new TenantBackendRolePermission { TenantId = seed.TenantId, RoleId = role.Id, PermissionCode = permissionCode }, new TenantBackendUserRole { TenantId = seed.TenantId, UserId = seed.UserId, RoleId = role.Id }); var tokens = await IssueAsync(factory, seed); Assert.True(TryLocate(factory, tokens.RefreshToken, out var locator)); using (var scope = factory.CreateSystemScope("Revoke final backend permission")) { var dbContext = scope.ServiceProvider.GetRequiredService(); var binding = await dbContext.TenantBackendRolePermissions.SingleAsync(item => item.TenantId == seed.TenantId && item.RoleId == role.Id); dbContext.TenantBackendRolePermissions.Remove(binding); await dbContext.SaveChangesAsync(); } using (var scope = factory.CreateSystemScope("Validate tenant session")) { var store = scope.ServiceProvider.GetRequiredService(); Assert.NotNull(await store.ValidateAccessSessionAsync( locator.SessionId, seed.UserId, AuthRealm.Tenant, seed.TenantId)); Assert.NotNull(await store.RotateAsync(tokens.RefreshToken, null, null)); } } private static bool TryLocate( ApiTestFactory factory, string refreshToken, out RefreshTokenLocator locator) { using var scope = factory.CreateSystemScope("Parse refresh token locator"); return scope.ServiceProvider.GetRequiredService() .TryParseRefreshToken(refreshToken, out locator); } private static async Task TryRotateAsync(IAuthSessionStore store, string refreshToken) { try { return await store.RotateAsync(refreshToken, null, null); } catch (SessionRevokedException) { return null; } } private static async Task IssueAsync( ApiTestFactory factory, SessionSeed seed) { using var scope = factory.CreateSystemScope("Issue authentication session"); return await scope.ServiceProvider.GetRequiredService().IssueAsync( new AuthSessionIssueRequest( seed.UserId, seed.Phone, null, seed.SecurityStamp, AuthRealm.Tenant, seed.TenantId, "integration-test", "127.0.0.1", "integration-test")); } private static async Task SeedActiveMemberAsync(ApiTestFactory factory) { var tenantId = Guid.NewGuid(); var user = new User { Id = Guid.NewGuid(), Phone = $"13{Random.Shared.Next(100_000_000, 1_000_000_000)}", Name = "Session lifecycle user" }; await factory.SeedAsync( new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Session lifecycle tenant", Status = TenantStatus.Active }, user, new TenantMembership { TenantId = tenantId, UserId = user.Id, Role = TenantRole.Student, Status = MembershipStatus.Active }); return new SessionSeed(tenantId, user.Id, user.Phone, user.SecurityStamp!); } private sealed record SessionSeed(Guid TenantId, Guid UserId, string Phone, string SecurityStamp); }