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