forked from xiongyuxing/tiku-backend.net
feat: enforce tenant isolation and shared question bank
This commit is contained in:
358
Tiku.IntegrationTests/PhaseThreeTenantIsolationTests.cs
Normal file
358
Tiku.IntegrationTests/PhaseThreeTenantIsolationTests.cs
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user