402 lines
15 KiB
C#
402 lines
15 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Tiku.Application.Learning;
|
|
using Tiku.Application.QuestionBanks;
|
|
using Tiku.Application.Security;
|
|
using Tiku.Domain.Catalog;
|
|
using Tiku.Domain.Common;
|
|
using Tiku.Domain.Content;
|
|
using Tiku.Domain.Identity;
|
|
using Tiku.Domain.Platform;
|
|
using Tiku.Domain.QuestionBanks;
|
|
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(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<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" });
|
|
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",
|
|
CorrectOptionIndex = 0
|
|
});
|
|
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",
|
|
CorrectOptionIndex = 0
|
|
});
|
|
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<IPracticeSessionService>()
|
|
.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",
|
|
CorrectOptionIndex = 0
|
|
});
|
|
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<IPracticeSessionService>()
|
|
.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)
|
|
{
|
|
return new Tenant
|
|
{
|
|
Id = id,
|
|
Slug = slug,
|
|
Name = slug,
|
|
Status = TenantStatus.Active,
|
|
Mode = mode
|
|
};
|
|
}
|
|
}
|