using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Tiku.Application.QuestionBanks; using Tiku.Application.Learning; using Tiku.Application.Security; 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.Platform; 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(); Assert.Empty(await dbContext.TenantBrandings.ToArrayAsync()); dbContext.TenantBrandings.Add(new TenantBranding { BrandName = "unresolved" }); await Assert.ThrowsAsync(() => 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(); 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(() => 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(() => 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(() => factory.SeedAsync(new TaxonomyNode { TenantId = tenantA, ParentOwnerTenantId = tenantB, ParentId = tenantBNodeId, Code = "tenant-a-forged-extension", Name = "Invalid Extension", NodeType = TaxonomyNodeType.Chapter })); } [Theory] [InlineData(TenantSaasSubscriptionStatus.Trial, true)] [InlineData(TenantSaasSubscriptionStatus.Active, true)] [InlineData(TenantSaasSubscriptionStatus.PastDue, false)] [InlineData(TenantSaasSubscriptionStatus.Cancelled, false)] public async Task Public_question_reference_requires_current_subscription( TenantSaasSubscriptionStatus subscriptionStatus, bool allowed) { await using var factory = new ApiTestFactory(); var platformId = Guid.NewGuid(); var tenantId = Guid.NewGuid(); var questionId = Guid.NewGuid(); var offeringId = Guid.NewGuid(); var versionId = Guid.NewGuid(); var subscriptionId = Guid.NewGuid(); var now = DateTimeOffset.UtcNow; await factory.SeedAsync( Tenant(platformId, "platform", TenantMode.PlatformOwned), Tenant(tenantId, "tenant-a"), new Question { Id = questionId, TenantId = platformId, Type = "choice", Status = QuestionStatus.Published }, new SaasFeature { Code = SaasFeatureCatalog.Practice, Name = "Practice", Category = "learning", Status = SaasFeatureStatus.Active }, new SaasOffering { Id = offeringId, Code = "standard", Name = "Standard", Type = SaasOfferingType.BasePlan, Status = SaasOfferingStatus.Active }, new SaasOfferingVersion { Id = versionId, OfferingId = offeringId, Status = SaasOfferingVersionStatus.Published, OriginalAmountCents = 100, AmountCents = 100, PublishedAt = now.AddDays(-1) }, new SaasOfferingVersionFeature { OfferingVersionId = versionId, FeatureCode = SaasFeatureCatalog.Practice }, new TenantSaasSubscription { Id = subscriptionId, TenantId = tenantId, BaseOfferingVersionId = versionId, Status = subscriptionStatus, StartsAt = now.AddDays(-1), CurrentPeriodStart = now.AddDays(-1), CurrentPeriodEnd = now.AddDays(1) }, new TenantSaasSubscriptionItem { TenantId = tenantId, SubscriptionId = subscriptionId, OfferingVersionId = versionId, Status = TenantSaasSubscriptionItemStatus.Active, StartsAt = now.AddDays(-1), EndsAt = now.AddDays(1) }); using var scope = factory.CreateTenantScope(tenantId, "tenant-a"); var service = scope.ServiceProvider.GetRequiredService(); var operation = async () => { var reference = await service.ResolveAsync( tenantId, null, new QuestionLocator(QuestionSource.Platform, questionId)); await scope.ServiceProvider.GetRequiredService().SaveChangesAsync(); return reference; }; if (allowed) { var reference = await operation(); Assert.Equal(platformId, reference.QuestionOwnerTenantId); } else { await Assert.ThrowsAsync(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" }); 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() .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(); 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() .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(); 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 }; }