forked from gongxuegit/tiku-backend.net
feat: enforce tenant isolation and shared question bank
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -33,7 +33,7 @@ public sealed class AssetAccessEndpointTests
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal("https://storage.example.test/download.pdf", body.RootElement.GetProperty("url").GetProperty("url").GetString());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Equal(1, dbContext.ContentAssets.Single(asset => asset.Id == assetId).DownloadCount);
|
||||
Assert.Contains(dbContext.ContentAssetAccessEvents, item =>
|
||||
@@ -251,7 +251,7 @@ public sealed class AssetAccessEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
|
||||
@@ -66,7 +66,7 @@ public sealed class AssetManagementEndpointTests
|
||||
var listItem = Assert.Single(list.RootElement.GetProperty("items").EnumerateArray());
|
||||
Assert.Equal(assetId, listItem.GetProperty("id").GetGuid());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var asset = dbContext.ContentAssets.Single(asset => asset.Id == assetId);
|
||||
Assert.Equal(seed.TenantId, asset.TenantId);
|
||||
@@ -118,7 +118,7 @@ public sealed class AssetManagementEndpointTests
|
||||
Assert.Equal("Verified", body.RootElement.GetProperty("item").GetProperty("uploadStatus").GetString());
|
||||
Assert.Equal(2048, body.RootElement.GetProperty("metadata").GetProperty("sizeBytes").GetInt64());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var asset = dbContext.ContentAssets.Single(asset => asset.Id == assetId);
|
||||
Assert.Equal(AssetUploadStatus.Verified, asset.UploadStatus);
|
||||
@@ -299,7 +299,7 @@ public sealed class AssetManagementEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
|
||||
@@ -13,6 +13,60 @@ namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class AuthEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Custom_host_rejects_jwt_from_another_tenant_and_ignores_spoofed_tenant_header()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantB = await SeedLoginUserAsync(factory);
|
||||
var tenantAId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantAId,
|
||||
Slug = "tenant-a",
|
||||
Name = "Tenant A",
|
||||
Status = TenantStatus.Active,
|
||||
Mode = TenantMode.Saas
|
||||
},
|
||||
new TenantDomain
|
||||
{
|
||||
TenantId = tenantAId,
|
||||
Host = "a.example.test",
|
||||
DomainType = TenantDomainType.Custom,
|
||||
Status = TenantDomainStatus.Active,
|
||||
IsPrimary = true
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
var loginResponse = await client.PostAsJsonAsync(
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantCode = tenantB.TenantId.ToString("N"),
|
||||
Phone = tenantB.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
var loginJson = await ReadJsonAsync(loginResponse);
|
||||
var accessToken = loginJson.RootElement.GetProperty("tokens").GetProperty("accessToken").GetString();
|
||||
|
||||
using var jwtRequest = new HttpRequestMessage(HttpMethod.Get, "/api/me");
|
||||
jwtRequest.Headers.Host = "a.example.test";
|
||||
jwtRequest.Headers.Authorization = new("Bearer", accessToken);
|
||||
var jwtResponse = await client.SendAsync(jwtRequest);
|
||||
|
||||
using var spoofRequest = new HttpRequestMessage(HttpMethod.Post, "/api/auth/login/password");
|
||||
spoofRequest.Headers.Host = "a.example.test";
|
||||
spoofRequest.Headers.Add("x-tenant-code", tenantB.TenantId.ToString("N"));
|
||||
spoofRequest.Content = JsonContent.Create(new PasswordLoginDto
|
||||
{
|
||||
Phone = tenantB.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
var spoofResponse = await client.SendAsync(spoofRequest);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, jwtResponse.StatusCode);
|
||||
Assert.NotEqual(HttpStatusCode.OK, spoofResponse.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Password_login_can_access_current_user_and_tenant()
|
||||
{
|
||||
@@ -24,7 +78,7 @@ public sealed class AuthEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
@@ -57,7 +111,7 @@ public sealed class AuthEndpointTests
|
||||
"/api/auth/login/sms",
|
||||
new SmsLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Code = "123456"
|
||||
});
|
||||
@@ -84,7 +138,7 @@ public sealed class AuthEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
@@ -136,7 +190,7 @@ public sealed class AuthEndpointTests
|
||||
"/api/auth/oauth/wechat-miniapp",
|
||||
new OAuthCodeDto
|
||||
{
|
||||
TenantId = tenantId,
|
||||
TenantCode = tenantId.ToString("N"),
|
||||
Code = "wx-code"
|
||||
});
|
||||
var loginJson = await ReadJsonAsync(loginResponse);
|
||||
@@ -147,7 +201,7 @@ public sealed class AuthEndpointTests
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
|
||||
var meResponse = await client.GetAsync("/api/me");
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode);
|
||||
@@ -204,7 +258,7 @@ public sealed class AuthEndpointTests
|
||||
string phone,
|
||||
string code)
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
dbContext.SmsVerificationCodes.Add(new SmsVerificationCode
|
||||
{
|
||||
|
||||
@@ -272,16 +272,16 @@ public sealed class CommerceEndpointTests
|
||||
signatureValid = true
|
||||
};
|
||||
var firstNotify = await client.PostAsJsonAsync(
|
||||
$"/api/commerce/payments/notify/wechat-pay?tenantId={seed.TenantId}",
|
||||
$"/api/commerce/payments/notify/wechat-pay?tenantCode={seed.TenantId:N}",
|
||||
payload);
|
||||
var secondNotify = await client.PostAsJsonAsync(
|
||||
$"/api/commerce/payments/notify/wechat-pay?tenantId={seed.TenantId}",
|
||||
$"/api/commerce/payments/notify/wechat-pay?tenantCode={seed.TenantId:N}",
|
||||
payload);
|
||||
await LoginAsync(client, seed);
|
||||
var entitlement = await (await client.GetAsync("/api/commerce/entitlements/current"))
|
||||
.Content
|
||||
.ReadFromJsonAsync<CurrentEntitlementItem>();
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var events = dbContext.PaymentEvents.Count(item => item.EventId == "notify-001");
|
||||
|
||||
@@ -302,7 +302,7 @@ public sealed class CommerceEndpointTests
|
||||
|
||||
client.DefaultRequestHeaders.Authorization = null;
|
||||
var notifyResponse = await client.PostAsJsonAsync(
|
||||
$"/api/commerce/payments/notify/wechat-pay?tenantId={seed.TenantId}",
|
||||
$"/api/commerce/payments/notify/wechat-pay?tenantCode={seed.TenantId:N}",
|
||||
new
|
||||
{
|
||||
eventId = "notify-invalid",
|
||||
@@ -403,7 +403,7 @@ public sealed class CommerceEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
|
||||
@@ -162,7 +162,7 @@ public sealed class CommissionEndpointTests
|
||||
|
||||
private static async Task LoginAsync(HttpClient client, LoginSeed seed)
|
||||
{
|
||||
var loginResponse = await client.PostAsJsonAsync("/api/auth/login/password", new PasswordLoginDto { TenantId = seed.TenantId, Phone = seed.Phone, Password = "passw0rd!" });
|
||||
var loginResponse = await client.PostAsJsonAsync("/api/auth/login/password", new PasswordLoginDto { TenantCode = seed.TenantId.ToString("N"), Phone = seed.Phone, Password = "passw0rd!" });
|
||||
loginResponse.EnsureSuccessStatusCode();
|
||||
using var loginJson = await JsonDocument.ParseAsync(await loginResponse.Content.ReadAsStreamAsync());
|
||||
client.DefaultRequestHeaders.Authorization = new("Bearer", loginJson.RootElement.GetProperty("tokens").GetProperty("accessToken").GetString());
|
||||
|
||||
@@ -141,7 +141,7 @@ public sealed class ContentManagementEndpointTests
|
||||
Assert.Equal(HttpStatusCode.OK, blueprintResponse.StatusCode);
|
||||
Assert.Equal(collectionId, blueprintJson.RootElement.GetProperty("item").GetProperty("collectionId").GetGuid());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Equal(1, dbContext.QuestionCollections.Single(item => item.Id == collectionId).QuestionCount);
|
||||
Assert.Contains(dbContext.QuestionCollectionItems, item => item.QuestionId == questionId);
|
||||
@@ -231,7 +231,7 @@ public sealed class ContentManagementEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
|
||||
@@ -201,15 +201,21 @@ public sealed class ContentNavigationEndpointTests
|
||||
PrimaryCollectionId = collectionId,
|
||||
Type = "choice",
|
||||
Status = QuestionStatus.Archived
|
||||
},
|
||||
new QuestionCollectionItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
CollectionId = collectionId,
|
||||
QuestionId = archivedQuestionId,
|
||||
SortOrder = 2
|
||||
});
|
||||
var archivedReference = await factory.SeedQuestionReferenceAsync(
|
||||
tenantId,
|
||||
tenantId,
|
||||
archivedQuestionId);
|
||||
await factory.SeedAsync(new QuestionCollectionItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
CollectionId = collectionId,
|
||||
QuestionReferenceId = archivedReference.Id,
|
||||
QuestionOwnerTenantId = tenantId,
|
||||
QuestionId = archivedQuestionId,
|
||||
SortOrder = 2
|
||||
});
|
||||
await factory.SeedQuestionWithVersionAsync(
|
||||
new Question
|
||||
{
|
||||
@@ -227,11 +233,17 @@ public sealed class ContentNavigationEndpointTests
|
||||
QuestionId = publishedQuestionId,
|
||||
Content = "题干"
|
||||
});
|
||||
var publishedReference = await factory.SeedQuestionReferenceAsync(
|
||||
tenantId,
|
||||
tenantId,
|
||||
publishedQuestionId);
|
||||
await factory.SeedAsync(new QuestionCollectionItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
CollectionId = collectionId,
|
||||
QuestionReferenceId = publishedReference.Id,
|
||||
QuestionOwnerTenantId = tenantId,
|
||||
QuestionId = publishedQuestionId,
|
||||
SortOrder = 1,
|
||||
Score = 2
|
||||
|
||||
@@ -55,7 +55,7 @@ public sealed class CrmEndpointTests
|
||||
Assert.Equal("tenant_secrets:crm:webhook:default", config!.SecretRef);
|
||||
Assert.Equal("RoundRobin", config.AssignmentMode);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Contains(dbContext.TenantSecrets, item =>
|
||||
item.TenantId == seed.TenantId &&
|
||||
@@ -146,7 +146,7 @@ public sealed class CrmEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ public sealed class DirectContentEndpointTests
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(collectionId, json.RootElement.GetProperty("item").GetProperty("primaryCollectionId").GetGuid());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Contains(dbContext.QuestionCollectionItems, item => item.CollectionId == collectionId && item.QuestionId == questionId);
|
||||
Assert.Equal(1, dbContext.QuestionCollections.Single(item => item.Id == collectionId).QuestionCount);
|
||||
@@ -168,7 +168,7 @@ public sealed class DirectContentEndpointTests
|
||||
Assert.Equal(HttpStatusCode.OK, postCheckResponse.StatusCode);
|
||||
Assert.NotEqual(Guid.Empty, previewJobId);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.True(dbContext.ContentImportJobs.Any(item => item.Id == executeJobId && item.InsertedCount == 1));
|
||||
Assert.True(dbContext.Questions.Count(item => item.TenantId == seed.TenantId) >= 2);
|
||||
@@ -274,7 +274,7 @@ public sealed class DirectContentEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ using Tiku.Api.Contracts;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Auth;
|
||||
@@ -32,13 +33,7 @@ public sealed class LearningEndpointTests
|
||||
await using var factory = new ApiTestFactory();
|
||||
var seed = await SeedLearningUserAsync(factory);
|
||||
var questionId = Guid.NewGuid();
|
||||
await factory.SeedAsync(new Question
|
||||
{
|
||||
Id = questionId,
|
||||
TenantId = seed.TenantId,
|
||||
Type = "choice",
|
||||
Status = QuestionStatus.Published
|
||||
});
|
||||
var sessionQuestionId = await SeedAnswerableQuestionAsync(factory, seed, questionId);
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client, seed);
|
||||
|
||||
@@ -46,7 +41,7 @@ public sealed class LearningEndpointTests
|
||||
"/api/learning/answers",
|
||||
new SubmitAnswerDto
|
||||
{
|
||||
QuestionId = questionId,
|
||||
SessionQuestionId = sessionQuestionId,
|
||||
SelectedOptions = ["A"],
|
||||
SelfJudgedCorrect = false
|
||||
});
|
||||
@@ -55,10 +50,10 @@ public sealed class LearningEndpointTests
|
||||
var wrongItems = await ReadItemsAsync(wrongResponse);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Equal(questionId, answer.RootElement.GetProperty("questionId").GetGuid());
|
||||
Assert.Equal(sessionQuestionId, answer.RootElement.GetProperty("sessionQuestionId").GetGuid());
|
||||
Assert.False(answer.RootElement.GetProperty("isCorrect").GetBoolean());
|
||||
var wrong = Assert.Single(wrongItems);
|
||||
Assert.Equal(questionId, wrong.GetProperty("questionId").GetGuid());
|
||||
Assert.Equal(questionId, wrong.GetProperty("locator").GetProperty("questionId").GetGuid());
|
||||
Assert.Equal(1, wrong.GetProperty("wrongCount").GetInt32());
|
||||
}
|
||||
|
||||
@@ -70,14 +65,9 @@ public sealed class LearningEndpointTests
|
||||
var collectionId = Guid.NewGuid();
|
||||
var firstQuestionId = Guid.NewGuid();
|
||||
var secondQuestionId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new QuestionCollection
|
||||
{
|
||||
Id = collectionId,
|
||||
TenantId = seed.TenantId,
|
||||
Name = "基础练习",
|
||||
Status = ContentStatus.Active
|
||||
},
|
||||
var firstVersionId = Guid.NewGuid();
|
||||
var secondVersionId = Guid.NewGuid();
|
||||
await factory.SeedQuestionWithVersionAsync(
|
||||
new Question
|
||||
{
|
||||
Id = firstQuestionId,
|
||||
@@ -85,6 +75,15 @@ public sealed class LearningEndpointTests
|
||||
Type = "choice",
|
||||
Status = QuestionStatus.Published
|
||||
},
|
||||
new QuestionVersion
|
||||
{
|
||||
Id = firstVersionId,
|
||||
TenantId = seed.TenantId,
|
||||
QuestionId = firstQuestionId,
|
||||
VersionNo = 1,
|
||||
Content = "first"
|
||||
});
|
||||
await factory.SeedQuestionWithVersionAsync(
|
||||
new Question
|
||||
{
|
||||
Id = secondQuestionId,
|
||||
@@ -92,11 +91,47 @@ public sealed class LearningEndpointTests
|
||||
Type = "choice",
|
||||
Status = QuestionStatus.Published
|
||||
},
|
||||
new QuestionVersion
|
||||
{
|
||||
Id = secondVersionId,
|
||||
TenantId = seed.TenantId,
|
||||
QuestionId = secondQuestionId,
|
||||
VersionNo = 1,
|
||||
Content = "second"
|
||||
});
|
||||
var firstReferenceId = Guid.NewGuid();
|
||||
var secondReferenceId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new TenantQuestionReference
|
||||
{
|
||||
Id = firstReferenceId,
|
||||
TenantId = seed.TenantId,
|
||||
QuestionOwnerTenantId = seed.TenantId,
|
||||
QuestionId = firstQuestionId,
|
||||
Source = QuestionSource.Tenant
|
||||
},
|
||||
new TenantQuestionReference
|
||||
{
|
||||
Id = secondReferenceId,
|
||||
TenantId = seed.TenantId,
|
||||
QuestionOwnerTenantId = seed.TenantId,
|
||||
QuestionId = secondQuestionId,
|
||||
Source = QuestionSource.Tenant
|
||||
},
|
||||
new QuestionCollection
|
||||
{
|
||||
Id = collectionId,
|
||||
TenantId = seed.TenantId,
|
||||
Name = "基础练习",
|
||||
Status = ContentStatus.Active
|
||||
},
|
||||
new QuestionCollectionItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = seed.TenantId,
|
||||
CollectionId = collectionId,
|
||||
QuestionReferenceId = firstReferenceId,
|
||||
QuestionOwnerTenantId = seed.TenantId,
|
||||
QuestionId = firstQuestionId,
|
||||
SortOrder = 1
|
||||
},
|
||||
@@ -105,6 +140,8 @@ public sealed class LearningEndpointTests
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = seed.TenantId,
|
||||
CollectionId = collectionId,
|
||||
QuestionReferenceId = secondReferenceId,
|
||||
QuestionOwnerTenantId = seed.TenantId,
|
||||
QuestionId = secondQuestionId,
|
||||
SortOrder = 2
|
||||
});
|
||||
@@ -124,13 +161,17 @@ public sealed class LearningEndpointTests
|
||||
var practiceSessionId = created.RootElement.GetProperty("id").GetGuid();
|
||||
var detailResponse = await client.GetAsync($"/api/learning/practice-sessions/detail?practiceSessionId={practiceSessionId}");
|
||||
var detail = await ReadJsonAsync(detailResponse);
|
||||
var detailQuestions = detail.RootElement.GetProperty("questions").EnumerateArray().ToArray();
|
||||
var firstSessionQuestionId = detailQuestions.Single(item =>
|
||||
item.GetProperty("questionId").GetGuid() == firstQuestionId).GetProperty("sessionQuestionId").GetGuid();
|
||||
var secondSessionQuestionId = detailQuestions.Single(item =>
|
||||
item.GetProperty("questionId").GetGuid() == secondQuestionId).GetProperty("sessionQuestionId").GetGuid();
|
||||
|
||||
await client.PostAsJsonAsync(
|
||||
"/api/learning/answers",
|
||||
new SubmitAnswerDto
|
||||
{
|
||||
QuestionId = firstQuestionId,
|
||||
PracticeSessionId = practiceSessionId,
|
||||
SessionQuestionId = firstSessionQuestionId,
|
||||
SelectedOptions = ["A"],
|
||||
SelfJudgedCorrect = true
|
||||
});
|
||||
@@ -138,8 +179,7 @@ public sealed class LearningEndpointTests
|
||||
"/api/learning/answers",
|
||||
new SubmitAnswerDto
|
||||
{
|
||||
QuestionId = secondQuestionId,
|
||||
PracticeSessionId = practiceSessionId,
|
||||
SessionQuestionId = secondSessionQuestionId,
|
||||
SelectedOptions = ["B"],
|
||||
SelfJudgedCorrect = false
|
||||
});
|
||||
@@ -199,7 +239,9 @@ public sealed class LearningEndpointTests
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, addResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
|
||||
Assert.Equal(questionId, Assert.Single(itemsAfterAdd).GetProperty("questionId").GetGuid());
|
||||
Assert.Equal(
|
||||
questionId,
|
||||
Assert.Single(itemsAfterAdd).GetProperty("locator").GetProperty("questionId").GetGuid());
|
||||
Assert.Equal(HttpStatusCode.OK, removeResponse.StatusCode);
|
||||
Assert.Empty(itemsAfterRemove);
|
||||
}
|
||||
@@ -304,14 +346,8 @@ public sealed class LearningEndpointTests
|
||||
var seed = await SeedLearningUserAsync(factory);
|
||||
var questionId = Guid.NewGuid();
|
||||
var wordId = Guid.NewGuid();
|
||||
var sessionQuestionId = await SeedAnswerableQuestionAsync(factory, seed, questionId);
|
||||
await factory.SeedAsync(
|
||||
new Question
|
||||
{
|
||||
Id = questionId,
|
||||
TenantId = seed.TenantId,
|
||||
Type = "choice",
|
||||
Status = QuestionStatus.Published
|
||||
},
|
||||
new VocabularyWord
|
||||
{
|
||||
Id = wordId,
|
||||
@@ -326,7 +362,7 @@ public sealed class LearningEndpointTests
|
||||
"/api/learning/answers",
|
||||
new SubmitAnswerDto
|
||||
{
|
||||
QuestionId = questionId,
|
||||
SessionQuestionId = sessionQuestionId,
|
||||
SelectedOptions = ["A"],
|
||||
SelfJudgedCorrect = false
|
||||
});
|
||||
@@ -409,6 +445,64 @@ public sealed class LearningEndpointTests
|
||||
return (tenantId, userId, phone);
|
||||
}
|
||||
|
||||
private static async Task<Guid> SeedAnswerableQuestionAsync(
|
||||
ApiTestFactory factory,
|
||||
(Guid TenantId, Guid UserId, string Phone) seed,
|
||||
Guid questionId)
|
||||
{
|
||||
var versionId = Guid.NewGuid();
|
||||
await factory.SeedQuestionWithVersionAsync(
|
||||
new Question
|
||||
{
|
||||
Id = questionId,
|
||||
TenantId = seed.TenantId,
|
||||
Type = "choice",
|
||||
Status = QuestionStatus.Published
|
||||
},
|
||||
new QuestionVersion
|
||||
{
|
||||
Id = versionId,
|
||||
TenantId = seed.TenantId,
|
||||
QuestionId = questionId,
|
||||
VersionNo = 1,
|
||||
Content = "answerable question"
|
||||
});
|
||||
|
||||
var referenceId = Guid.NewGuid();
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessionQuestionId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new TenantQuestionReference
|
||||
{
|
||||
Id = referenceId,
|
||||
TenantId = seed.TenantId,
|
||||
QuestionOwnerTenantId = seed.TenantId,
|
||||
QuestionId = questionId,
|
||||
Source = QuestionSource.Tenant
|
||||
},
|
||||
new PracticeSession
|
||||
{
|
||||
Id = sessionId,
|
||||
TenantId = seed.TenantId,
|
||||
UserId = seed.UserId,
|
||||
Mode = "single",
|
||||
QuestionCount = 1,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30)
|
||||
},
|
||||
new PracticeSessionQuestion
|
||||
{
|
||||
Id = sessionQuestionId,
|
||||
TenantId = seed.TenantId,
|
||||
PracticeSessionId = sessionId,
|
||||
QuestionReferenceId = referenceId,
|
||||
QuestionOwnerTenantId = seed.TenantId,
|
||||
QuestionId = questionId,
|
||||
QuestionVersionId = versionId,
|
||||
Position = 0
|
||||
});
|
||||
return sessionQuestionId;
|
||||
}
|
||||
|
||||
private static async Task LoginAsync(
|
||||
HttpClient client,
|
||||
(Guid TenantId, Guid UserId, string Phone) seed)
|
||||
@@ -417,7 +511,7 @@ public sealed class LearningEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
|
||||
@@ -159,7 +159,7 @@ public sealed class PointsEndpointTests
|
||||
Assert.Equal(30, summary.SpentPoints);
|
||||
Assert.Equal(70, summary.BalancePoints);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Contains(dbContext.Entitlements, item =>
|
||||
item.TenantId == seed.TenantId &&
|
||||
@@ -252,7 +252,7 @@ public sealed class PointsEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
|
||||
@@ -183,7 +183,7 @@ public sealed class ProfileEndpointTests
|
||||
Assert.Equal(HttpStatusCode.OK, feedbacksResponse.StatusCode);
|
||||
Assert.Single(feedbacksJson.RootElement.EnumerateArray());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Equal(NotificationStatus.Read, dbContext.UserNotifications.Single(item => item.Id == notificationId).Status);
|
||||
Assert.Single(dbContext.ReportStatusEvents);
|
||||
@@ -237,7 +237,7 @@ public sealed class ProfileEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
|
||||
@@ -97,11 +97,17 @@ public sealed class QuestionBankEndpointTests
|
||||
VersionNo = 2,
|
||||
Content = "题干关键词"
|
||||
});
|
||||
var questionReference = await factory.SeedQuestionReferenceAsync(
|
||||
tenantId,
|
||||
tenantId,
|
||||
includedQuestionId);
|
||||
await factory.SeedAsync(new QuestionCollectionItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
CollectionId = collectionId,
|
||||
QuestionReferenceId = questionReference.Id,
|
||||
QuestionOwnerTenantId = tenantId,
|
||||
QuestionId = includedQuestionId
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
@@ -114,7 +114,7 @@ public sealed class ReferralEndpointTests
|
||||
Assert.Equal($"ref={qrcodeItem.RefCode}", qrcodeGenerator.LastRequest.Scene);
|
||||
Assert.Equal("fake-referral-qrcode-generator", qrcodeItem.Metadata.GetProperty("generatedBy").GetString());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Contains(dbContext.ReferralLeads, item =>
|
||||
item.TenantId == seed.TenantId &&
|
||||
@@ -294,7 +294,7 @@ public sealed class ReferralEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
|
||||
@@ -35,14 +35,15 @@ public sealed class SecurityFoundationTests
|
||||
{
|
||||
await using var factory = CreateFactory();
|
||||
var userId = Guid.NewGuid();
|
||||
var sessionId = await factory.SeedActiveSessionAsync(userId);
|
||||
var tenantId = Guid.NewGuid();
|
||||
var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new(
|
||||
"Bearer",
|
||||
CreateToken([
|
||||
new Claim(TikuClaimTypes.UserId, userId.ToString()),
|
||||
new Claim(TikuClaimTypes.SessionId, sessionId.ToString()),
|
||||
new Claim(TikuClaimTypes.TenantId, Guid.NewGuid().ToString()),
|
||||
new Claim(TikuClaimTypes.TenantId, tenantId.ToString()),
|
||||
new Claim(TikuClaimTypes.TenantRole, TenantRole.Student.ToString())
|
||||
]));
|
||||
|
||||
@@ -56,13 +57,15 @@ public sealed class SecurityFoundationTests
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
await using var factory = CreateFactory();
|
||||
var sessionId = await factory.SeedActiveSessionAsync(userId);
|
||||
var tenantId = Guid.NewGuid();
|
||||
var sessionId = await factory.SeedActiveSessionAsync(userId, tenantId);
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new(
|
||||
"Bearer",
|
||||
CreateToken([
|
||||
new Claim(TikuClaimTypes.UserId, userId.ToString()),
|
||||
new Claim(TikuClaimTypes.SessionId, sessionId.ToString())
|
||||
new Claim(TikuClaimTypes.SessionId, sessionId.ToString()),
|
||||
new Claim(TikuClaimTypes.TenantId, tenantId.ToString())
|
||||
]));
|
||||
|
||||
var response = await client.GetAsync("/api/_security/authenticated");
|
||||
|
||||
@@ -66,7 +66,7 @@ public sealed class TenantAdminDirectEndpointTests
|
||||
Assert.Equal(studentUserId, listJson.RootElement.GetProperty("items")[0].GetProperty("userId").GetGuid());
|
||||
Assert.Equal(1, classesJson.RootElement.GetProperty("items")[0].GetProperty("studentCount").GetInt32());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.True(await dbContext.StudentProfiles.AnyAsync(profile => profile.TenantId == seed.TenantId && profile.UserId == studentUserId));
|
||||
Assert.True(await dbContext.TenantMemberships.AnyAsync(member => member.TenantId == seed.TenantId && member.UserId == studentUserId && member.Role == TenantRole.Student));
|
||||
@@ -148,7 +148,7 @@ public sealed class TenantAdminDirectEndpointTests
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, statusResponse.StatusCode);
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.True(await dbContext.AuthSessions.AnyAsync(session => session.UserId == studentId && session.RevokedAt != null));
|
||||
}
|
||||
@@ -239,7 +239,7 @@ public sealed class TenantAdminDirectEndpointTests
|
||||
Assert.Equal(HttpStatusCode.OK, authProviderResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, secretSettingsResponse.StatusCode);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.True(await dbContext.TenantBrandings.AnyAsync(item => item.TenantId == seed.TenantId && item.BrandName == "机构题库"));
|
||||
Assert.True(await dbContext.TenantThemeConfigs.AnyAsync(item => item.TenantId == seed.TenantId && item.ActiveTemplateCode == "classic"));
|
||||
@@ -377,7 +377,7 @@ public sealed class TenantAdminDirectEndpointTests
|
||||
Assert.True(notificationsJson.RootElement.GetProperty("items").GetArrayLength() >= 2);
|
||||
Assert.Single(feedbackJson.RootElement.GetProperty("items").EnumerateArray());
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.True(await dbContext.UserBadges.AnyAsync(item => item.TenantId == seed.TenantId && item.UserId == studentId && item.BadgeId == badgeId));
|
||||
Assert.True(await dbContext.UserNotifications.AnyAsync(item => item.TenantId == seed.TenantId && item.UserId == studentId && item.NotificationType == "badge_granted"));
|
||||
@@ -435,7 +435,7 @@ public sealed class TenantAdminDirectEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ public sealed class TenantCommerceEndpointTests
|
||||
Assert.Equal(HttpStatusCode.OK, accountsResponse.StatusCode);
|
||||
Assert.Contains("wechat_pay", await accountsResponse.Content.ReadAsStringAsync(), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var storedSecret = await dbContext.TenantSecrets
|
||||
.AsNoTracking()
|
||||
@@ -143,7 +143,7 @@ public sealed class TenantCommerceEndpointTests
|
||||
Assert.Equal(2, codes.Items.Count);
|
||||
Assert.Equal(HttpStatusCode.OK, redeemResponse.StatusCode);
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
using var scope = factory.CreateSystemScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Contains(dbContext.Entitlements, item =>
|
||||
item.TenantId == seed.TenantId &&
|
||||
@@ -247,7 +247,7 @@ public sealed class TenantCommerceEndpointTests
|
||||
"/api/auth/login/password",
|
||||
new PasswordLoginDto
|
||||
{
|
||||
TenantId = seed.TenantId,
|
||||
TenantCode = seed.TenantId.ToString("N"),
|
||||
Phone = seed.Phone,
|
||||
Password = "passw0rd!"
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Tenancy;
|
||||
@@ -9,6 +11,59 @@ namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class TenantPublicEndpointTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Runtime_bootstrap_uses_published_config_etag_and_invalidates_after_publish()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantId = Guid.NewGuid();
|
||||
await SeedTenantAsync(factory, tenantId);
|
||||
await factory.SeedAsync(new TenantFrontendConfig
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ConfigVersion = 1,
|
||||
PublishedBranding = JsonSerializer.SerializeToElement(new { name = "published" }),
|
||||
DraftBranding = JsonSerializer.SerializeToElement(new { name = "published" })
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
|
||||
using var firstRequest = new HttpRequestMessage(HttpMethod.Get, "/api/runtime/bootstrap");
|
||||
firstRequest.Headers.Host = "student.example.test";
|
||||
var first = await client.SendAsync(firstRequest);
|
||||
var firstBody = await first.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var firstEtag = first.Headers.ETag?.Tag;
|
||||
|
||||
using var notModifiedRequest = new HttpRequestMessage(HttpMethod.Get, "/api/runtime/bootstrap");
|
||||
notModifiedRequest.Headers.Host = "student.example.test";
|
||||
notModifiedRequest.Headers.TryAddWithoutValidation("If-None-Match", firstEtag);
|
||||
var notModified = await client.SendAsync(notModifiedRequest);
|
||||
|
||||
using (var scope = factory.CreateTenantScope(tenantId, "master"))
|
||||
{
|
||||
var service = scope.ServiceProvider.GetRequiredService<ITenantFrontendConfigService>();
|
||||
await service.SaveDraftAsync(
|
||||
tenantId,
|
||||
new TenantFrontendConfigDraft(
|
||||
JsonSerializer.SerializeToElement(new { name = "draft" }),
|
||||
JsonDefaults.Object(),
|
||||
JsonDefaults.Object(),
|
||||
JsonDefaults.Array(),
|
||||
JsonDefaults.Array()));
|
||||
await service.PublishAsync(tenantId, 1);
|
||||
}
|
||||
|
||||
using var publishedRequest = new HttpRequestMessage(HttpMethod.Get, "/api/runtime/bootstrap");
|
||||
publishedRequest.Headers.Host = "student.example.test";
|
||||
var published = await client.SendAsync(publishedRequest);
|
||||
var publishedBody = await published.Content.ReadFromJsonAsync<JsonElement>();
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
|
||||
Assert.Equal("published", firstBody.GetProperty("branding").GetProperty("name").GetString());
|
||||
Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, published.StatusCode);
|
||||
Assert.NotEqual(firstEtag, published.Headers.ETag?.Tag);
|
||||
Assert.Equal("draft", publishedBody.GetProperty("branding").GetProperty("name").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resolve_returns_public_tenant_configuration_by_tenant_code()
|
||||
{
|
||||
|
||||
52
Tiku.IntegrationTests/ArchitectureBoundaryTests.cs
Normal file
52
Tiku.IntegrationTests/ArchitectureBoundaryTests.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
namespace Tiku.IntegrationTests;
|
||||
|
||||
public sealed class ArchitectureBoundaryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Business_code_does_not_bypass_tenant_query_boundaries()
|
||||
{
|
||||
var root = FindRepositoryRoot();
|
||||
var sourceRoots = new[] { "Tiku.Api", "Tiku.Application", "Tiku.Infrastructure" };
|
||||
var forbidden = new[]
|
||||
{
|
||||
"IgnoreQueryFilters(",
|
||||
"FromSql(",
|
||||
"FromSqlRaw(",
|
||||
"FromSqlInterpolated(",
|
||||
"ExecuteSql(",
|
||||
"ExecuteSqlRaw(",
|
||||
"ExecuteSqlInterpolated(",
|
||||
"new NpgsqlCommand("
|
||||
};
|
||||
|
||||
var violations = sourceRoots
|
||||
.SelectMany(directory => Directory.EnumerateFiles(
|
||||
Path.Combine(root, directory),
|
||||
"*.cs",
|
||||
SearchOption.AllDirectories))
|
||||
.Where(path => !path.Contains(
|
||||
$"{Path.DirectorySeparatorChar}Persistence{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}",
|
||||
StringComparison.Ordinal))
|
||||
.SelectMany(path => File.ReadLines(path)
|
||||
.Select((line, index) => new { path, line, lineNumber = index + 1 }))
|
||||
.Where(candidate => forbidden.Any(symbol =>
|
||||
candidate.line.Contains(symbol, StringComparison.Ordinal)))
|
||||
.Select(candidate => $"{Path.GetRelativePath(root, candidate.path)}:{candidate.lineNumber}")
|
||||
.ToArray();
|
||||
|
||||
Assert.True(
|
||||
violations.Length == 0,
|
||||
$"Forbidden tenant-boundary bypasses were found:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}");
|
||||
}
|
||||
|
||||
private static string FindRepositoryRoot()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "TIKU-BACKEND.slnx")))
|
||||
{
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root was not found.");
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,11 @@ public sealed class PersistenceModelTests
|
||||
.Select(entity => entity.GetTableName())
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
Assert.Equal(133, tableNames.Count);
|
||||
Assert.Contains("tenants", tableNames);
|
||||
Assert.Contains("tenant_frontend_configs", tableNames);
|
||||
Assert.Contains("tenant_question_references", tableNames);
|
||||
Assert.Contains("taxonomy_nodes", tableNames);
|
||||
Assert.Contains("practice_session_questions", tableNames);
|
||||
Assert.Contains("tenant_settings", tableNames);
|
||||
Assert.Contains("content_entries", tableNames);
|
||||
Assert.Contains("content_nodes", tableNames);
|
||||
@@ -102,8 +105,7 @@ public sealed class PersistenceModelTests
|
||||
Assert.Contains("point_exchange_orders", tableNames);
|
||||
Assert.Contains("content_asset_access_events", tableNames);
|
||||
Assert.Contains("content_asset_security_scan_events", tableNames);
|
||||
Assert.Contains("question_bank_grants", tableNames);
|
||||
Assert.Contains("tenant_question_bank_adoptions", tableNames);
|
||||
Assert.Contains("tenant_question_bank_preferences", tableNames);
|
||||
Assert.Contains("ai_recommendation_reports", tableNames);
|
||||
Assert.Contains("referral_tracks", tableNames);
|
||||
Assert.Contains("referral_codes", tableNames);
|
||||
@@ -361,9 +363,7 @@ public sealed class PersistenceModelTests
|
||||
[InlineData(typeof(ContentImportIssue), nameof(ContentImportIssue.Details), "'{}'::jsonb")]
|
||||
[InlineData(typeof(VideoExplanation), nameof(VideoExplanation.KnowledgeTags), "'[]'::jsonb")]
|
||||
[InlineData(typeof(QuestionVideo), nameof(QuestionVideo.Metadata), "'{}'::jsonb")]
|
||||
[InlineData(typeof(QuestionBankGrant), nameof(QuestionBankGrant.Metadata), "'{}'::jsonb")]
|
||||
[InlineData(typeof(TenantQuestionBankAdoption), nameof(TenantQuestionBankAdoption.SourceSnapshot), "'{}'::jsonb")]
|
||||
[InlineData(typeof(TenantQuestionBankAdoption), nameof(TenantQuestionBankAdoption.Metadata), "'{}'::jsonb")]
|
||||
[InlineData(typeof(TenantQuestionBankPreference), nameof(TenantQuestionBankPreference.Metadata), "'{}'::jsonb")]
|
||||
[InlineData(typeof(AiRecommendationReport), nameof(AiRecommendationReport.InputPayload), "'{}'::jsonb")]
|
||||
[InlineData(typeof(AiRecommendationReport), nameof(AiRecommendationReport.ContextPayload), "'{}'::jsonb")]
|
||||
[InlineData(typeof(AiRecommendationReport), nameof(AiRecommendationReport.ResultPayload), "'{}'::jsonb")]
|
||||
@@ -388,6 +388,7 @@ public sealed class PersistenceModelTests
|
||||
using var context = new TikuDbContext(Options);
|
||||
|
||||
AssertHasUniqueIndex<ContentImportItem>(
|
||||
nameof(ContentImportItem.TenantId),
|
||||
nameof(ContentImportItem.JobId),
|
||||
nameof(ContentImportItem.RowNo));
|
||||
AssertHasUniqueIndex<QuestionVideo>(
|
||||
@@ -395,17 +396,10 @@ public sealed class PersistenceModelTests
|
||||
nameof(QuestionVideo.QuestionId),
|
||||
nameof(QuestionVideo.VideoId),
|
||||
nameof(QuestionVideo.VideoType));
|
||||
AssertHasUniqueIndex<TenantQuestionBankAdoption>(
|
||||
nameof(TenantQuestionBankAdoption.TenantId),
|
||||
nameof(TenantQuestionBankAdoption.SourceQuestionBankId));
|
||||
|
||||
var allowedPlanCodes = context.Model.FindEntityType(typeof(QuestionBankGrant))!
|
||||
.FindProperty(nameof(QuestionBankGrant.AllowedPlanCodes))!;
|
||||
Assert.Equal("text[]", allowedPlanCodes.GetColumnType());
|
||||
|
||||
var allowedTenantIds = context.Model.FindEntityType(typeof(QuestionBankGrant))!
|
||||
.FindProperty(nameof(QuestionBankGrant.AllowedTenantIds))!;
|
||||
Assert.Equal("uuid[]", allowedTenantIds.GetColumnType());
|
||||
AssertHasUniqueIndex<TenantQuestionBankPreference>(
|
||||
nameof(TenantQuestionBankPreference.TenantId),
|
||||
nameof(TenantQuestionBankPreference.QuestionBankOwnerTenantId),
|
||||
nameof(TenantQuestionBankPreference.QuestionBankId));
|
||||
|
||||
void AssertHasUniqueIndex<TEntity>(params string[] propertyNames)
|
||||
{
|
||||
@@ -635,6 +629,7 @@ public sealed class PersistenceModelTests
|
||||
nameof(CommerceRefundRequest.TenantId),
|
||||
nameof(CommerceRefundRequest.RefundNo));
|
||||
AssertHasUniqueIndex<CommerceReconciliationItem>(
|
||||
nameof(CommerceReconciliationItem.TenantId),
|
||||
nameof(CommerceReconciliationItem.BatchId),
|
||||
nameof(CommerceReconciliationItem.RowNo));
|
||||
AssertHasUniqueIndex<CommerceReconciliationIssue>(
|
||||
@@ -848,7 +843,9 @@ public sealed class PersistenceModelTests
|
||||
using var context = new TikuDbContext(Options);
|
||||
|
||||
AssertHasUniqueIndex<PlatformSaasPlan>(nameof(PlatformSaasPlan.Code));
|
||||
AssertHasUniqueIndex<TenantInvoice>(nameof(TenantInvoice.InvoiceNo));
|
||||
AssertHasUniqueIndex<TenantInvoice>(
|
||||
nameof(TenantInvoice.TenantId),
|
||||
nameof(TenantInvoice.InvoiceNo));
|
||||
AssertHasUniqueIndex<TenantInvoice>(
|
||||
nameof(TenantInvoice.TenantId),
|
||||
nameof(TenantInvoice.BillingPeriodStart),
|
||||
@@ -863,6 +860,7 @@ public sealed class PersistenceModelTests
|
||||
AssertHasUniqueIndex<PlatformAuditAlert>(nameof(PlatformAuditAlert.RuleId), nameof(PlatformAuditAlert.AuditLogId));
|
||||
AssertHasUniqueIndex<PlatformDunningNotificationChannel>(nameof(PlatformDunningNotificationChannel.ChannelCode));
|
||||
AssertHasUniqueIndex<PlatformDunningNotificationEvent>(
|
||||
nameof(PlatformDunningNotificationEvent.TenantId),
|
||||
nameof(PlatformDunningNotificationEvent.ChannelId),
|
||||
nameof(PlatformDunningNotificationEvent.ReminderId));
|
||||
|
||||
|
||||
358
Tiku.IntegrationTests/PhaseThreeTenantIsolationTests.cs
Normal file
358
Tiku.IntegrationTests/PhaseThreeTenantIsolationTests.cs
Normal file
@@ -0,0 +1,358 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Domain.Common;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Learning;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.IntegrationTests.Api;
|
||||
|
||||
namespace Tiku.IntegrationTests;
|
||||
|
||||
public sealed class PhaseThreeTenantIsolationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Unresolved_context_reads_no_tenant_rows_and_rejects_writes()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantId = Guid.NewGuid();
|
||||
await factory.SeedAsync(Tenant(tenantId, "tenant-a"), new TenantBranding
|
||||
{
|
||||
TenantId = tenantId,
|
||||
BrandName = "Tenant A"
|
||||
});
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
Assert.Empty(await dbContext.TenantBrandings.ToArrayAsync());
|
||||
dbContext.TenantBrandings.Add(new TenantBranding { BrandName = "unresolved" });
|
||||
await Assert.ThrowsAsync<TenantIsolationException>(() => dbContext.SaveChangesAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_a_cannot_read_or_attach_tenant_b_data()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantA = Guid.NewGuid();
|
||||
var tenantB = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
Tenant(tenantA, "tenant-a"),
|
||||
Tenant(tenantB, "tenant-b"),
|
||||
new TenantBranding { TenantId = tenantA, BrandName = "A" },
|
||||
new TenantBranding { TenantId = tenantB, BrandName = "B" });
|
||||
|
||||
using var scope = factory.CreateTenantScope(tenantA, "tenant-a");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var visible = await dbContext.TenantBrandings.AsNoTracking().ToArrayAsync();
|
||||
Assert.Equal(tenantA, Assert.Single(visible).TenantId);
|
||||
|
||||
var forged = new TenantBranding { TenantId = tenantB, BrandName = "forged" };
|
||||
dbContext.Attach(forged);
|
||||
forged.BrandName = "modified";
|
||||
dbContext.Entry(forged).Property(item => item.BrandName).IsModified = true;
|
||||
await Assert.ThrowsAsync<TenantIsolationException>(() => dbContext.SaveChangesAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostgreSql_rejects_reference_to_another_tenant_private_question()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantA = Guid.NewGuid();
|
||||
var tenantB = Guid.NewGuid();
|
||||
var questionId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
Tenant(tenantA, "tenant-a"),
|
||||
Tenant(tenantB, "tenant-b"),
|
||||
new Question
|
||||
{
|
||||
Id = questionId,
|
||||
TenantId = tenantB,
|
||||
Type = "choice",
|
||||
Status = QuestionStatus.Published
|
||||
});
|
||||
|
||||
await Assert.ThrowsAsync<DbUpdateException>(() => factory.SeedAsync(new TenantQuestionReference
|
||||
{
|
||||
TenantId = tenantA,
|
||||
QuestionOwnerTenantId = tenantB,
|
||||
QuestionId = questionId,
|
||||
Source = QuestionSource.Tenant
|
||||
}));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Taxonomy_extension_accepts_platform_parent_and_rejects_other_tenant_parent()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var platformId = Guid.NewGuid();
|
||||
var tenantA = Guid.NewGuid();
|
||||
var tenantB = Guid.NewGuid();
|
||||
var platformNodeId = Guid.NewGuid();
|
||||
var tenantBNodeId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
Tenant(platformId, "platform", TenantMode.PlatformOwned),
|
||||
Tenant(tenantA, "tenant-a"),
|
||||
Tenant(tenantB, "tenant-b"),
|
||||
new TaxonomyNode
|
||||
{
|
||||
Id = platformNodeId,
|
||||
TenantId = platformId,
|
||||
Code = "platform-subject",
|
||||
Name = "Platform Subject",
|
||||
NodeType = TaxonomyNodeType.Subject
|
||||
},
|
||||
new TaxonomyNode
|
||||
{
|
||||
Id = tenantBNodeId,
|
||||
TenantId = tenantB,
|
||||
Code = "tenant-b-subject",
|
||||
Name = "Tenant B Subject",
|
||||
NodeType = TaxonomyNodeType.Subject
|
||||
});
|
||||
|
||||
await factory.SeedAsync(new TaxonomyNode
|
||||
{
|
||||
TenantId = tenantA,
|
||||
ParentOwnerTenantId = platformId,
|
||||
ParentId = platformNodeId,
|
||||
Code = "tenant-a-platform-extension",
|
||||
Name = "Valid Extension",
|
||||
NodeType = TaxonomyNodeType.Chapter
|
||||
});
|
||||
|
||||
await Assert.ThrowsAsync<DbUpdateException>(() => factory.SeedAsync(new TaxonomyNode
|
||||
{
|
||||
TenantId = tenantA,
|
||||
ParentOwnerTenantId = tenantB,
|
||||
ParentId = tenantBNodeId,
|
||||
Code = "tenant-a-forged-extension",
|
||||
Name = "Invalid Extension",
|
||||
NodeType = TaxonomyNodeType.Chapter
|
||||
}));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(TenantSubscriptionStatus.Trial, true)]
|
||||
[InlineData(TenantSubscriptionStatus.Active, true)]
|
||||
[InlineData(TenantSubscriptionStatus.PastDue, false)]
|
||||
[InlineData(TenantSubscriptionStatus.Cancelled, false)]
|
||||
public async Task Public_question_reference_requires_current_subscription(
|
||||
TenantSubscriptionStatus subscriptionStatus,
|
||||
bool allowed)
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var platformId = Guid.NewGuid();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var questionId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
Tenant(platformId, "platform", TenantMode.PlatformOwned),
|
||||
Tenant(tenantId, "tenant-a"),
|
||||
new Question
|
||||
{
|
||||
Id = questionId,
|
||||
TenantId = platformId,
|
||||
Type = "choice",
|
||||
Status = QuestionStatus.Published
|
||||
},
|
||||
new TenantSubscription
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PlanCode = "standard",
|
||||
Status = subscriptionStatus,
|
||||
StartsAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(1)
|
||||
});
|
||||
|
||||
using var scope = factory.CreateTenantScope(tenantId, "tenant-a");
|
||||
var service = scope.ServiceProvider.GetRequiredService<IQuestionReferenceService>();
|
||||
var operation = async () =>
|
||||
{
|
||||
var reference = await service.ResolveAsync(
|
||||
tenantId,
|
||||
null,
|
||||
new QuestionLocator(QuestionSource.Platform, questionId));
|
||||
await scope.ServiceProvider.GetRequiredService<TikuDbContext>().SaveChangesAsync();
|
||||
return reference;
|
||||
};
|
||||
|
||||
if (allowed)
|
||||
{
|
||||
var reference = await operation();
|
||||
Assert.Equal(platformId, reference.QuestionOwnerTenantId);
|
||||
}
|
||||
else
|
||||
{
|
||||
await Assert.ThrowsAsync<PublicQuestionAccessDeniedException>(operation);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mixed_collection_pins_versions_and_new_session_uses_new_platform_version()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var platformId = Guid.NewGuid();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var userId = Guid.NewGuid();
|
||||
var platformQuestionId = Guid.NewGuid();
|
||||
var privateQuestionId = Guid.NewGuid();
|
||||
var platformV1 = Guid.NewGuid();
|
||||
var platformV2 = Guid.NewGuid();
|
||||
var privateV1 = Guid.NewGuid();
|
||||
var collectionId = Guid.NewGuid();
|
||||
var platformReferenceId = Guid.NewGuid();
|
||||
var privateReferenceId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
Tenant(platformId, "platform", TenantMode.PlatformOwned),
|
||||
Tenant(tenantId, "tenant-a"),
|
||||
new User { Id = userId, Phone = "13800009999" },
|
||||
new TenantSubscription
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PlanCode = "standard",
|
||||
Status = TenantSubscriptionStatus.Active,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
|
||||
});
|
||||
await factory.SeedQuestionWithVersionAsync(
|
||||
new Question
|
||||
{
|
||||
Id = platformQuestionId,
|
||||
TenantId = platformId,
|
||||
Type = "choice",
|
||||
Status = QuestionStatus.Published
|
||||
},
|
||||
new QuestionVersion
|
||||
{
|
||||
Id = platformV1,
|
||||
TenantId = platformId,
|
||||
QuestionId = platformQuestionId,
|
||||
VersionNo = 1,
|
||||
Content = "platform-v1"
|
||||
});
|
||||
await factory.SeedQuestionWithVersionAsync(
|
||||
new Question
|
||||
{
|
||||
Id = privateQuestionId,
|
||||
TenantId = tenantId,
|
||||
Type = "choice",
|
||||
Status = QuestionStatus.Published
|
||||
},
|
||||
new QuestionVersion
|
||||
{
|
||||
Id = privateV1,
|
||||
TenantId = tenantId,
|
||||
QuestionId = privateQuestionId,
|
||||
VersionNo = 1,
|
||||
Content = "private-v1"
|
||||
});
|
||||
await factory.SeedAsync(
|
||||
new TenantQuestionReference
|
||||
{
|
||||
Id = platformReferenceId,
|
||||
TenantId = tenantId,
|
||||
QuestionOwnerTenantId = platformId,
|
||||
QuestionId = platformQuestionId,
|
||||
Source = QuestionSource.Platform
|
||||
},
|
||||
new TenantQuestionReference
|
||||
{
|
||||
Id = privateReferenceId,
|
||||
TenantId = tenantId,
|
||||
QuestionOwnerTenantId = tenantId,
|
||||
QuestionId = privateQuestionId,
|
||||
Source = QuestionSource.Tenant
|
||||
},
|
||||
new QuestionCollection
|
||||
{
|
||||
Id = collectionId,
|
||||
TenantId = tenantId,
|
||||
Name = "mixed",
|
||||
Status = ContentStatus.Active
|
||||
},
|
||||
new QuestionCollectionItem
|
||||
{
|
||||
TenantId = tenantId,
|
||||
CollectionId = collectionId,
|
||||
QuestionReferenceId = platformReferenceId,
|
||||
QuestionOwnerTenantId = platformId,
|
||||
QuestionId = platformQuestionId,
|
||||
SortOrder = 1
|
||||
},
|
||||
new QuestionCollectionItem
|
||||
{
|
||||
TenantId = tenantId,
|
||||
CollectionId = collectionId,
|
||||
QuestionReferenceId = privateReferenceId,
|
||||
QuestionOwnerTenantId = tenantId,
|
||||
QuestionId = privateQuestionId,
|
||||
SortOrder = 2
|
||||
});
|
||||
|
||||
Guid firstSessionId;
|
||||
using (var scope = factory.CreateTenantScope(tenantId, "tenant-a"))
|
||||
{
|
||||
var created = await scope.ServiceProvider.GetRequiredService<ILearningActivityService>()
|
||||
.CreatePracticeSessionAsync(
|
||||
new LearningActor(tenantId, userId),
|
||||
new PracticeSessionCommand(
|
||||
"collection", null, null, null, collectionId, null, null, 10, null, null, JsonDefaults.Object()));
|
||||
firstSessionId = created.Id;
|
||||
}
|
||||
|
||||
await factory.SeedAsync(new QuestionVersion
|
||||
{
|
||||
Id = platformV2,
|
||||
TenantId = platformId,
|
||||
QuestionId = platformQuestionId,
|
||||
VersionNo = 2,
|
||||
Content = "platform-v2"
|
||||
});
|
||||
using (var scope = factory.CreateSystemScope("Publish platform question V2"))
|
||||
{
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var question = await dbContext.Questions.SingleAsync(item =>
|
||||
item.TenantId == platformId && item.Id == platformQuestionId);
|
||||
question.CurrentVersionId = platformV2;
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
Guid secondSessionId;
|
||||
using (var scope = factory.CreateTenantScope(tenantId, "tenant-a"))
|
||||
{
|
||||
var created = await scope.ServiceProvider.GetRequiredService<ILearningActivityService>()
|
||||
.CreatePracticeSessionAsync(
|
||||
new LearningActor(tenantId, userId),
|
||||
new PracticeSessionCommand(
|
||||
"collection", null, null, null, collectionId, null, null, 10, null, null, JsonDefaults.Object()));
|
||||
secondSessionId = created.Id;
|
||||
}
|
||||
|
||||
using var verificationScope = factory.CreateSystemScope();
|
||||
var verificationDb = verificationScope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var firstQuestions = await verificationDb.PracticeSessionQuestions
|
||||
.Where(item => item.PracticeSessionId == firstSessionId)
|
||||
.ToArrayAsync();
|
||||
var secondQuestions = await verificationDb.PracticeSessionQuestions
|
||||
.Where(item => item.PracticeSessionId == secondSessionId)
|
||||
.ToArrayAsync();
|
||||
Assert.Equal(2, firstQuestions.Length);
|
||||
Assert.Equal(2, secondQuestions.Length);
|
||||
Assert.Equal(platformV1, firstQuestions.Single(item => item.QuestionId == platformQuestionId).QuestionVersionId);
|
||||
Assert.Equal(platformV2, secondQuestions.Single(item => item.QuestionId == platformQuestionId).QuestionVersionId);
|
||||
Assert.Equal(privateV1, firstQuestions.Single(item => item.QuestionId == privateQuestionId).QuestionVersionId);
|
||||
}
|
||||
|
||||
private static Tenant Tenant(Guid id, string slug, TenantMode mode = TenantMode.Saas) => new()
|
||||
{
|
||||
Id = id,
|
||||
Slug = slug,
|
||||
Name = slug,
|
||||
Status = TenantStatus.Active,
|
||||
Mode = mode
|
||||
};
|
||||
}
|
||||
114
Tiku.IntegrationTests/TenantDomainLifecycleTests.cs
Normal file
114
Tiku.IntegrationTests/TenantDomainLifecycleTests.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.IntegrationTests.Api;
|
||||
|
||||
namespace Tiku.IntegrationTests;
|
||||
|
||||
public sealed class TenantDomainLifecycleTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Domain_becomes_active_only_after_dns_and_tls_are_ready()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(
|
||||
domainOwnershipVerifier: new FakeOwnershipVerifier(true),
|
||||
domainGatewayProvisioner: new FakeGatewayProvisioner(true));
|
||||
var tenantId = Guid.NewGuid();
|
||||
var domainId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = "tenant-a",
|
||||
Name = "Tenant A",
|
||||
Status = TenantStatus.Active,
|
||||
Mode = TenantMode.Saas
|
||||
},
|
||||
new TenantDomain
|
||||
{
|
||||
Id = domainId,
|
||||
TenantId = tenantId,
|
||||
Host = "learn.tenant-a.example",
|
||||
DomainType = TenantDomainType.Custom,
|
||||
Status = TenantDomainStatus.Pending,
|
||||
VerificationToken = "verification-token"
|
||||
});
|
||||
|
||||
using (var scope = factory.CreateSystemScope("Process domain lifecycle"))
|
||||
{
|
||||
var processed = await scope.ServiceProvider
|
||||
.GetRequiredService<ITenantDomainLifecycleService>()
|
||||
.ProcessPendingAsync();
|
||||
Assert.Equal(1, processed);
|
||||
}
|
||||
|
||||
using var verificationScope = factory.CreateSystemScope();
|
||||
var domain = await verificationScope.ServiceProvider.GetRequiredService<TikuDbContext>()
|
||||
.TenantDomains.SingleAsync(item => item.Id == domainId);
|
||||
Assert.Equal(TenantDomainStatus.Active, domain.Status);
|
||||
Assert.NotNull(domain.DnsVerifiedAt);
|
||||
Assert.NotNull(domain.TlsReadyAt);
|
||||
Assert.Null(domain.LastFailureReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Missing_gateway_configuration_keeps_domain_pending()
|
||||
{
|
||||
await using var factory = new ApiTestFactory(
|
||||
domainOwnershipVerifier: new FakeOwnershipVerifier(true),
|
||||
domainGatewayProvisioner: new FakeGatewayProvisioner(false, configured: false));
|
||||
var tenantId = Guid.NewGuid();
|
||||
var domainId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new Tenant
|
||||
{
|
||||
Id = tenantId,
|
||||
Slug = "tenant-a",
|
||||
Name = "Tenant A",
|
||||
Status = TenantStatus.Active,
|
||||
Mode = TenantMode.Saas
|
||||
},
|
||||
new TenantDomain
|
||||
{
|
||||
Id = domainId,
|
||||
TenantId = tenantId,
|
||||
Host = "pending.tenant-a.example",
|
||||
Status = TenantDomainStatus.Pending,
|
||||
VerificationToken = "verification-token"
|
||||
});
|
||||
|
||||
using (var scope = factory.CreateSystemScope("Process pending domain"))
|
||||
{
|
||||
await scope.ServiceProvider.GetRequiredService<ITenantDomainLifecycleService>().ProcessPendingAsync();
|
||||
}
|
||||
|
||||
using var verificationScope = factory.CreateSystemScope();
|
||||
var domain = await verificationScope.ServiceProvider.GetRequiredService<TikuDbContext>()
|
||||
.TenantDomains.SingleAsync(item => item.Id == domainId);
|
||||
Assert.Equal(TenantDomainStatus.Pending, domain.Status);
|
||||
Assert.NotNull(domain.DnsVerifiedAt);
|
||||
Assert.Contains("not configured", domain.LastFailureReason, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed class FakeOwnershipVerifier(bool verified) : IDomainOwnershipVerifier
|
||||
{
|
||||
public Task<DomainOwnershipResult> VerifyAsync(
|
||||
string host,
|
||||
string verificationToken,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new DomainOwnershipResult(verified, true, verified ? null : "DNS failed"));
|
||||
}
|
||||
|
||||
private sealed class FakeGatewayProvisioner(bool ready, bool configured = true) : IDomainGatewayProvisioner
|
||||
{
|
||||
public Task<DomainGatewayResult> EnsureTlsAsync(
|
||||
string host,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new DomainGatewayResult(
|
||||
ready,
|
||||
configured,
|
||||
ready ? null : configured ? "TLS pending" : "Gateway is not configured"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user