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)

View File

@@ -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!"
});

View File

@@ -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!"
});

View File

@@ -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
{

View File

@@ -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!"
});

View File

@@ -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());

View File

@@ -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!"
});

View File

@@ -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

View File

@@ -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!"
});

View File

@@ -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!"
});

View File

@@ -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!"
});

View File

@@ -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!"
});

View File

@@ -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!"
});

View File

@@ -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();

View File

@@ -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!"
});

View File

@@ -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");

View File

@@ -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!"
});

View File

@@ -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!"
});

View File

@@ -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()
{

View 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.");
}
}

View File

@@ -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));

View 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
};
}

View 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"));
}
}