Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/Modules/Identity/Modules.Identity/AssemblyInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
using System.Runtime.CompilerServices;

[assembly: FshModule(typeof(FSH.Modules.Identity.IdentityModule), 100)]
[assembly: InternalsVisibleTo("Identity.Tests")]
[assembly: InternalsVisibleTo("Identity.Tests")]
[assembly: InternalsVisibleTo("Integration.Tests")]
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
using Finbuckle.MultiTenant;
using Finbuckle.MultiTenant.Abstractions;
using FSH.Framework.Shared.Multitenancy;
using FSH.Modules.Identity.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
Expand All @@ -8,7 +11,7 @@ namespace FSH.Modules.Identity.Services;

/// <summary>
/// Background service that periodically cleans up expired sessions.
/// Runs every hour and removes sessions that have been expired for more than 30 days.
/// Runs every hour and, in every tenant, removes sessions that have been expired for more than 30 days.
/// </summary>
public sealed class SessionCleanupHostedService : BackgroundService
{
Expand Down Expand Up @@ -36,8 +39,8 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
await Task.Delay(_cleanupInterval, stoppingToken);
await CleanupExpiredSessionsAsync(stoppingToken);
await Task.Delay(_cleanupInterval, stoppingToken).ConfigureAwait(false);
await CleanupExpiredSessionsAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
Expand All @@ -54,20 +57,50 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
_logger.LogInformation("Session cleanup service stopped");
}

private async Task CleanupExpiredSessionsAsync(CancellationToken cancellationToken)
internal async Task CleanupExpiredSessionsAsync(CancellationToken cancellationToken)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
// A hosted service has no request, so no tenant is resolved and the default-on tenant filter on
// UserSessions has nothing to compare against. Each tenant is cleaned inside its own context,
// which also points IdentityDbContext at a tenant's dedicated database when it has one.
List<AppTenantInfo> tenants;
using (var scope = _scopeFactory.CreateScope())
{
var tenantStore = scope.ServiceProvider.GetRequiredService<IMultiTenantStore<AppTenantInfo>>();
tenants = (await tenantStore.GetAllAsync().ConfigureAwait(false)).ToList();
}

// cutoffDate = now - retentionDays, so ExpiresAt < cutoffDate already implies ExpiresAt < now.
var cutoffDate = _timeProvider.GetUtcNow().UtcDateTime.AddDays(-_retentionDays);
var deleted = await db.UserSessions
.Where(s => s.ExpiresAt < cutoffDate)
.ExecuteDeleteAsync(cancellationToken);
foreach (var tenant in tenants)
{
cancellationToken.ThrowIfCancellationRequested();
await CleanupTenantSessionsAsync(tenant, cutoffDate, cancellationToken).ConfigureAwait(false);
}
}

private async Task CleanupTenantSessionsAsync(AppTenantInfo tenant, DateTime cutoffDate, CancellationToken cancellationToken)
{
try
{
using var scope = _scopeFactory.CreateScope();
scope.ServiceProvider.GetRequiredService<IMultiTenantContextSetter>()
.MultiTenantContext = new MultiTenantContext<AppTenantInfo>(tenant);

var db = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
var deleted = await db.UserSessions
.Where(s => s.ExpiresAt < cutoffDate)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);

if (deleted > 0 && _logger.IsEnabled(LogLevel.Information))
if (deleted > 0 && _logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation("Cleaned up {Count} expired sessions for tenant {TenantId}", deleted, tenant.Id);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogInformation("Cleaned up {Count} expired sessions", deleted);
// One tenant's database being unreachable must not keep the other tenants' sessions around.
_logger.LogError(ex, "Session cleanup failed for tenant {TenantId}", tenant.Id);
}
}
}
165 changes: 165 additions & 0 deletions src/Tests/Integration.Tests/Tests/Sessions/SessionCleanupTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
using Finbuckle.MultiTenant;
using Finbuckle.MultiTenant.Abstractions;
using FSH.Framework.Shared.Multitenancy;
using FSH.Modules.Identity.Data;
using FSH.Modules.Identity.Domain;
using FSH.Modules.Identity.Services;
using FSH.Modules.Multitenancy.Contracts.Dtos;
using Integration.Tests.Infrastructure;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Hosting;

namespace Integration.Tests.Tests.Sessions;

/// <summary>
/// The hourly session cleanup runs outside any request, so there is no ambient tenant. UserSessions
/// carry the default-on tenant filter, which means the cleanup only reaches a tenant's rows when it
/// runs inside that tenant's context. Sessions are seeded in two tenants and the registered hosted
/// service's single run is invoked directly, so the test does not wait for the hourly timer.
/// </summary>
[Collection(FshCollectionDefinition.Name)]
public sealed class SessionCleanupTests
{
// The service keeps sessions for 30 days past expiry; 31 is safely past the cutoff, 1 is inside it.
private const int DaysPastRetention = 31;
private const int DaysInsideRetention = 1;

private readonly FshWebApplicationFactory _factory;
private readonly AuthHelper _auth;

public SessionCleanupTests(FshWebApplicationFactory factory)
{
_factory = factory;
_auth = new AuthHelper(factory);
}

[Fact]
public async Task Cleanup_Should_DeleteSessionsPastRetention_InEveryTenant_And_KeepTheRest()
{
// Arrange
using var rootClient = await _auth.CreateRootAdminClientAsync();
var uniqueId = Guid.NewGuid().ToString("N")[..8];
var otherTenantId = $"sess-clean-{uniqueId}";
var otherAdminEmail = $"sess-clean-admin-{uniqueId}@tenant.com";

await CreateTenantAsync(rootClient, otherTenantId, otherAdminEmail);
await WaitForProvisioningAsync(rootClient, otherTenantId);

var now = DateTime.UtcNow;
var rootExpired = await SeedSessionAsync(
TestConstants.RootTenantId, TestConstants.RootAdminEmail, now.AddDays(-DaysPastRetention));
var otherExpired = await SeedSessionAsync(
otherTenantId, otherAdminEmail, now.AddDays(-DaysPastRetention));
var rootInsideRetention = await SeedSessionAsync(
TestConstants.RootTenantId, TestConstants.RootAdminEmail, now.AddDays(-DaysInsideRetention));
var otherActive = await SeedSessionAsync(
otherTenantId, otherAdminEmail, now.AddDays(7));

var cleanup = _factory.Services.GetServices<IHostedService>()
.OfType<SessionCleanupHostedService>()
.Single();

// Act
await cleanup.CleanupExpiredSessionsAsync(CancellationToken.None);

// Assert
(await SessionExistsAsync(TestConstants.RootTenantId, rootExpired))
.ShouldBeFalse("a root-tenant session expired past retention must be deleted");
(await SessionExistsAsync(otherTenantId, otherExpired))
.ShouldBeFalse("a session expired past retention in a non-root tenant must be deleted");
(await SessionExistsAsync(TestConstants.RootTenantId, rootInsideRetention))
.ShouldBeTrue("a session expired inside the retention window must be kept");
(await SessionExistsAsync(otherTenantId, otherActive))
.ShouldBeTrue("a session that has not expired must be kept");
}

// Tenant context is an AsyncLocal, so it is set in the same method as the DbContext call.
private async Task<Guid> SeedSessionAsync(string tenantId, string userEmail, DateTime expiresAt)
{
using var scope = _factory.Services.CreateScope();
var tenant = await scope.ServiceProvider
.GetRequiredService<IMultiTenantStore<AppTenantInfo>>()
.GetAsync(tenantId);
tenant.ShouldNotBeNull();
scope.ServiceProvider.GetRequiredService<IMultiTenantContextSetter>()
.MultiTenantContext = new MultiTenantContext<AppTenantInfo>(tenant);

var user = await scope.ServiceProvider
.GetRequiredService<UserManager<FshUser>>()
.FindByEmailAsync(userEmail);
user.ShouldNotBeNull();

var session = UserSession.Create(
user.Id,
Guid.NewGuid().ToString("N"),
"127.0.0.1",
"session-cleanup-test",
expiresAt);

var db = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
db.UserSessions.Add(session);
await db.SaveChangesAsync();
return session.Id;
}

private async Task<bool> SessionExistsAsync(string tenantId, Guid sessionId)
{
using var scope = _factory.Services.CreateScope();
var tenant = await scope.ServiceProvider
.GetRequiredService<IMultiTenantStore<AppTenantInfo>>()
.GetAsync(tenantId);
tenant.ShouldNotBeNull();
scope.ServiceProvider.GetRequiredService<IMultiTenantContextSetter>()
.MultiTenantContext = new MultiTenantContext<AppTenantInfo>(tenant);

var db = scope.ServiceProvider.GetRequiredService<IdentityDbContext>();
return await db.UserSessions.AsNoTracking().AnyAsync(s => s.Id == sessionId);
}

private static async Task CreateTenantAsync(HttpClient rootClient, string tenantId, string adminEmail)
{
var response = await rootClient.PostAsJsonAsync(TestConstants.TenantsBasePath, new
{
id = tenantId,
name = $"Tenant {tenantId}",
connectionString = (string?)null,
adminEmail,
adminPassword = TestConstants.DefaultPassword,
issuer = $"{tenantId}.issuer"
});
var body = await response.Content.ReadAsStringAsync();
response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}");
}

// The status body also lists each step, and a finished step reads "Completed" while later steps
// (seeding the tenant admin) are still running, so only the overall Status field is trusted.
private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId)
{
const int maxRetries = 60;
for (int i = 0; i < maxRetries; i++)
{
var statusResponse = await client.GetAsync(
$"{TestConstants.TenantsBasePath}/{tenantId}/provisioning");

if (statusResponse.IsSuccessStatusCode)
{
var status = await statusResponse.Content.ReadFromJsonAsync<TenantProvisioningStatusDto>();
if (string.Equals(status?.Status, "Completed", StringComparison.Ordinal))
{
return;
}

if (string.Equals(status?.Status, "Failed", StringComparison.Ordinal))
{
throw new InvalidOperationException(
$"Tenant {tenantId} provisioning failed at {status?.CurrentStep}: {status?.Error}");
}
}

await Task.Delay(1000);
}

throw new TimeoutException(
$"Tenant {tenantId} provisioning did not complete within {maxRetries} seconds.");
}
}
Loading