Files
tiku-backend.net/Tiku.IntegrationTests/PhaseThreeTenantIsolationTests.cs

390 lines
15 KiB
C#

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<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<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",
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<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
};
}