feat: enforce tenant isolation and shared question bank

This commit is contained in:
2026-07-27 16:59:12 +08:00
parent 28e9a9fa41
commit db4c7b4496
137 changed files with 6402 additions and 112274 deletions

View File

@@ -1,13 +1,17 @@
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Npgsql;
using Tiku.Application.Commerce;
using Tiku.Application.Auth;
using Tiku.Application.Growth;
using Tiku.Application.Storage;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Domain.Identity;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Content;
using Tiku.Domain.Tenancy;
using Tiku.IntegrationTests.Infrastructure;
using Tiku.Infrastructure.Persistence;
@@ -18,7 +22,9 @@ public sealed class ApiTestFactory(
IWechatOAuthClient? wechatOAuthClient = null,
IObjectStorageService? objectStorageService = null,
IReferralQrcodeGenerator? referralQrcodeGenerator = null,
IPaymentProviderGateway? paymentProviderGateway = null) : WebApplicationFactory<Program>
IPaymentProviderGateway? paymentProviderGateway = null,
IDomainOwnershipVerifier? domainOwnershipVerifier = null,
IDomainGatewayProvisioner? domainGatewayProvisioner = null) : WebApplicationFactory<Program>
{
private readonly PostgresTestDatabase database = PostgresTestDatabase.Create();
@@ -29,6 +35,7 @@ public sealed class ApiTestFactory(
foreach (var descriptor in services
.Where(descriptor =>
descriptor.ServiceType == typeof(NpgsqlDataSource) ||
descriptor.ServiceType == typeof(TenantIsolationSaveChangesInterceptor) ||
descriptor.ServiceType == typeof(DbContextOptions<TikuDbContext>) ||
descriptor.ServiceType.FullName?.Contains(nameof(TikuDbContext), StringComparison.Ordinal) == true)
.ToArray())
@@ -37,11 +44,13 @@ public sealed class ApiTestFactory(
}
services.AddSingleton(_ => NpgsqlDataSource.Create(database.ConnectionString));
services.AddDbContextPool<TikuDbContext>((serviceProvider, options) =>
services.AddScoped<TenantIsolationSaveChangesInterceptor>();
services.AddDbContext<TikuDbContext>((serviceProvider, options) =>
{
var dataSource = serviceProvider.GetRequiredService<NpgsqlDataSource>();
options.UseNpgsql(dataSource, npgsql =>
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
options.AddInterceptors(serviceProvider.GetRequiredService<TenantIsolationSaveChangesInterceptor>());
});
if (wechatOAuthClient is not null)
@@ -63,17 +72,47 @@ public sealed class ApiTestFactory(
{
services.AddSingleton(paymentProviderGateway);
}
if (domainOwnershipVerifier is not null)
{
services.RemoveAll<IDomainOwnershipVerifier>();
services.AddSingleton(domainOwnershipVerifier);
}
if (domainGatewayProvisioner is not null)
{
services.RemoveAll<IDomainGatewayProvisioner>();
services.AddSingleton(domainGatewayProvisioner);
}
});
}
public async Task SeedAsync(params object[] entities)
{
using var scope = Services.CreateScope();
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
.InitializeSystem(null, "Integration test fixture seeding");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
dbContext.AddRange(entities);
await dbContext.SaveChangesAsync();
}
public IServiceScope CreateSystemScope(string reason = "Integration test verification")
{
var scope = Services.CreateScope();
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
.InitializeSystem(null, reason);
return scope;
}
public IServiceScope CreateTenantScope(Guid tenantId, string? tenantCode = null)
{
var scope = Services.CreateScope();
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
.Initialize(tenantId, tenantCode, TenantResolutionSource.TenantCode);
return scope;
}
public async Task SeedQuestionWithVersionAsync(Question question, QuestionVersion version)
{
question.CurrentVersionId = null;
@@ -81,6 +120,8 @@ public sealed class ApiTestFactory(
await SeedAsync(version);
using var scope = Services.CreateScope();
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
.InitializeSystem(question.TenantId, "Integration test question version linking");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var persistedQuestion = await dbContext.Questions.SingleAsync(item =>
item.TenantId == question.TenantId && item.Id == question.Id);
@@ -88,6 +129,24 @@ public sealed class ApiTestFactory(
await dbContext.SaveChangesAsync();
}
public async Task<TenantQuestionReference> SeedQuestionReferenceAsync(
Guid tenantId,
Guid questionOwnerTenantId,
Guid questionId,
QuestionSource source = QuestionSource.Tenant)
{
var reference = new TenantQuestionReference
{
Id = Guid.NewGuid(),
TenantId = tenantId,
QuestionOwnerTenantId = questionOwnerTenantId,
QuestionId = questionId,
Source = source
};
await SeedAsync(reference);
return reference;
}
public async Task<Guid> SeedActiveSessionAsync(
Guid userId,
Guid? tenantId = null,
@@ -117,6 +176,8 @@ public sealed class ApiTestFactory(
});
using var scope = Services.CreateScope();
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
.InitializeSystem(resolvedTenantId, "Integration test session lookup");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
return await dbContext.AuthSessions
.Where(session => session.UserId == userId)