forked from gongxuegit/tiku-backend.net
feat(security): complete capability messaging workflows
This commit is contained in:
@@ -16,6 +16,8 @@ using Tiku.Domain.Identity;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.QuestionBanks;
|
||||
using Tiku.Domain.Content;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.IntegrationTests.Infrastructure;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
@@ -134,6 +136,54 @@ public sealed class ApiTestFactory(
|
||||
scope.ServiceProvider.GetRequiredService<ITenantContextInitializer>()
|
||||
.InitializeSystem(null, "Integration test fixture seeding");
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
var tenants = entities.OfType<Tenant>().Where(tenant => tenant.Mode == TenantMode.Saas).ToArray();
|
||||
var hasExplicitCapabilitySetup = entities.Any(entity =>
|
||||
entity is PlatformSaasPlan or ProductModule or PlanModuleEntitlement or TenantSubscription);
|
||||
if (tenants.Length > 0 && !hasExplicitCapabilitySetup)
|
||||
{
|
||||
const string integrationPlanCode = "integration-full-access";
|
||||
if (!await dbContext.PlatformSaasPlans.AnyAsync(plan => plan.Code == integrationPlanCode))
|
||||
{
|
||||
dbContext.PlatformSaasPlans.Add(new PlatformSaasPlan
|
||||
{
|
||||
Code = integrationPlanCode,
|
||||
Name = "Integration Full Access"
|
||||
});
|
||||
}
|
||||
|
||||
var existingModules = await dbContext.ProductModules
|
||||
.Select(module => module.Code)
|
||||
.ToArrayAsync();
|
||||
foreach (var module in ProductModuleCatalog.All.Where(module => !existingModules.Contains(module.Key)))
|
||||
{
|
||||
dbContext.ProductModules.Add(new ProductModule { Code = module.Key, Name = module.Value });
|
||||
}
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var entitledModules = await dbContext.PlanModuleEntitlements
|
||||
.Where(entitlement => entitlement.PlanCode == integrationPlanCode)
|
||||
.Select(entitlement => entitlement.ModuleCode)
|
||||
.ToArrayAsync();
|
||||
foreach (var moduleCode in ProductModuleCatalog.All.Keys.Except(entitledModules, StringComparer.Ordinal))
|
||||
{
|
||||
dbContext.PlanModuleEntitlements.Add(new PlanModuleEntitlement
|
||||
{
|
||||
PlanCode = integrationPlanCode,
|
||||
ModuleCode = moduleCode
|
||||
});
|
||||
}
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
entities = entities.Concat(tenants.Select(tenant => new TenantSubscription
|
||||
{
|
||||
TenantId = tenant.Id,
|
||||
PlanCode = integrationPlanCode,
|
||||
Status = TenantSubscriptionStatus.Active,
|
||||
StartsAt = now.AddDays(-1),
|
||||
ExpiresAt = now.AddYears(1)
|
||||
})).ToArray();
|
||||
}
|
||||
dbContext.AddRange(entities);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
|
||||
@@ -7,14 +7,17 @@ using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MassTransit;
|
||||
using Tiku.Api.Security;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
|
||||
namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class AuthorizationManifestTests
|
||||
{
|
||||
private const int ExpectedActionCount = 330;
|
||||
private const string ExpectedSha256 = "ad09167662cb9dc25111f40902c5f16a6633465f0ca7e7da0e50cdc10cfb8bb5";
|
||||
private const int ExpectedActionCount = 332;
|
||||
private const string ExpectedSha256 = "a80fe477ba3021625e17c9fc639e5109bab678178f8024a51c3c732bf5a46d3f";
|
||||
|
||||
[Fact]
|
||||
public void Controller_authorization_surface_matches_reviewed_manifest()
|
||||
@@ -61,6 +64,30 @@ public sealed class AuthorizationManifestTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Message_consumers_have_reviewed_authorization_and_audit_metadata()
|
||||
{
|
||||
var consumers = typeof(MessagingOptions).Assembly.GetTypes()
|
||||
.Where(type => !type.IsAbstract && type.GetInterfaces().Any(candidate =>
|
||||
candidate.IsGenericType && candidate.GetGenericTypeDefinition() == typeof(IConsumer<>)))
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(2, consumers.Length);
|
||||
foreach (var consumer in consumers)
|
||||
{
|
||||
var metadata = consumer.GetCustomAttribute<ConsumerAuthorizationMetadataAttribute>();
|
||||
Assert.NotNull(metadata);
|
||||
Assert.Contains(metadata.Realm, new[] { "tenant", "platform", "system" });
|
||||
Assert.False(string.IsNullOrWhiteSpace(metadata.Module));
|
||||
Assert.False(string.IsNullOrWhiteSpace(metadata.AuditAction));
|
||||
if (consumer.Name == "BackgroundJobRequestedConsumer")
|
||||
{
|
||||
Assert.Equal(CapabilityOperation.Write, metadata.Operation);
|
||||
Assert.True(metadata.RequiresSystemScope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string Describe(Type controller, MethodInfo action)
|
||||
{
|
||||
var controllerRoute = controller.GetCustomAttribute<RouteAttribute>()?.Template ?? string.Empty;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Text.Json;
|
||||
using Tiku.Application.Jobs;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Domain.Commerce;
|
||||
using Tiku.Domain.Operations;
|
||||
using Tiku.Domain.Platform;
|
||||
using Tiku.Domain.Tenancy;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
@@ -9,6 +13,48 @@ namespace Tiku.IntegrationTests.Api;
|
||||
|
||||
public sealed class CapabilityAuthorizationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Background_job_rechecks_capability_after_enqueue_before_execution()
|
||||
{
|
||||
await using var factory = new ApiTestFactory();
|
||||
var tenantId = Guid.NewGuid();
|
||||
await factory.SeedAsync(
|
||||
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Job Capability Tenant" },
|
||||
new PlatformSaasPlan { Code = "job-capability-test", Name = "Job Capability Test" },
|
||||
new PlanModuleEntitlement { PlanCode = "job-capability-test", ModuleCode = "content" },
|
||||
new TenantSubscription
|
||||
{
|
||||
TenantId = tenantId,
|
||||
PlanCode = "job-capability-test",
|
||||
Status = TenantSubscriptionStatus.Active,
|
||||
StartsAt = DateTimeOffset.UtcNow.AddDays(-1),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30)
|
||||
});
|
||||
|
||||
using var scope = factory.CreateSystemScope("Verify job capability at consumption");
|
||||
var jobs = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
|
||||
var job = await jobs.EnqueueAsync(new CreateBackgroundJobCommand(
|
||||
tenantId,
|
||||
"content_export",
|
||||
JsonSerializer.SerializeToElement(new { exportType = "capability-test" })));
|
||||
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
|
||||
dbContext.TenantModuleOverrides.Add(new TenantModuleOverride
|
||||
{
|
||||
TenantId = tenantId,
|
||||
ModuleCode = "content",
|
||||
Mode = TenantModuleOverrideMode.Disabled,
|
||||
Reason = "Integration test revocation"
|
||||
});
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
Assert.True(await jobs.ProcessRequestedAsync(
|
||||
job.Id, tenantId, job.JobType, "capability-test-worker"));
|
||||
var stored = await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == job.Id);
|
||||
Assert.Equal(BackgroundJobStatus.Failed, stored.Status);
|
||||
Assert.Equal("Tenant capability was revoked before job execution.", stored.LastError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Entitlement_is_database_backed_and_past_due_is_read_only()
|
||||
{
|
||||
@@ -17,7 +63,6 @@ public sealed class CapabilityAuthorizationTests
|
||||
await factory.SeedAsync(
|
||||
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Capability Tenant" },
|
||||
new PlatformSaasPlan { Code = "capability-test", Name = "Capability Test" },
|
||||
new ProductModule { Code = "content", Name = "Content" },
|
||||
new TenantSubscription
|
||||
{
|
||||
TenantId = tenantId,
|
||||
|
||||
@@ -62,6 +62,9 @@ public sealed class PlatformAdminEndpointTests
|
||||
|
||||
var overview = await client.GetAsync("/api/platform-admin/overview");
|
||||
var tenants = await client.GetAsync("/api/platform-admin/tenants?search=six-a");
|
||||
var planModules = await client.PutAsJsonAsync(
|
||||
"/api/platform-admin/plans/standard/modules",
|
||||
new ReplacePlatformPlanModulesDto { ModuleCodes = ["content", "job", "settings"] });
|
||||
var subscription = await client.PostAsJsonAsync(
|
||||
"/api/platform-admin/subscriptions",
|
||||
new UpsertPlatformSubscriptionDto
|
||||
@@ -73,6 +76,13 @@ public sealed class PlatformAdminEndpointTests
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
AmountCents = 99900
|
||||
});
|
||||
var moduleOverride = await client.PutAsJsonAsync(
|
||||
$"/api/platform-admin/tenants/{tenantId}/module-overrides/content",
|
||||
new UpsertPlatformTenantModuleOverrideDto
|
||||
{
|
||||
Mode = TenantModuleOverrideMode.Disabled,
|
||||
Reason = "integration test capability revocation"
|
||||
});
|
||||
var recheck = await client.PostAsync($"/api/platform-admin/domains/{domainId}/recheck", null);
|
||||
var suspended = await client.PatchAsJsonAsync(
|
||||
"/api/platform-admin/tenants/status",
|
||||
@@ -90,7 +100,9 @@ public sealed class PlatformAdminEndpointTests
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, overview.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, tenants.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, planModules.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, subscription.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, moduleOverride.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, recheck.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, suspended.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, runtimeAfterSuspend.StatusCode);
|
||||
@@ -103,6 +115,12 @@ public sealed class PlatformAdminEndpointTests
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
|
||||
log.ActorUserId == platform.UserId &&
|
||||
log.Action == "platform.tenant.status_changed"));
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
|
||||
log.ActorUserId == platform.UserId &&
|
||||
log.Action == "platform.plan.modules.replaced"));
|
||||
Assert.True(await dbContext.AuditLogs.AnyAsync(log =>
|
||||
log.ActorUserId == platform.UserId &&
|
||||
log.Action == "platform.tenant.module_override.updated"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -17,6 +17,7 @@ public sealed class QuestionBankEndpointTests
|
||||
var otherTenantId = Guid.NewGuid();
|
||||
await using var factory = new ApiTestFactory();
|
||||
await factory.SeedAsync(
|
||||
PlatformTenant(),
|
||||
Tenant(tenantId, "master"),
|
||||
Tenant(otherTenantId, "other"),
|
||||
new QuestionBank
|
||||
@@ -62,6 +63,7 @@ public sealed class QuestionBankEndpointTests
|
||||
var versionId = Guid.NewGuid();
|
||||
await using var factory = new ApiTestFactory();
|
||||
await factory.SeedAsync(
|
||||
PlatformTenant(),
|
||||
Tenant(tenantId, "master"),
|
||||
new Subject { Id = subjectId, TenantId = tenantId, Name = "测试科目" },
|
||||
new Category { Id = categoryId, TenantId = tenantId, SubjectId = subjectId, Name = "测试分类" },
|
||||
@@ -130,6 +132,7 @@ public sealed class QuestionBankEndpointTests
|
||||
var questionId = Guid.NewGuid();
|
||||
await using var factory = new ApiTestFactory();
|
||||
await factory.SeedAsync(
|
||||
PlatformTenant(),
|
||||
Tenant(tenantId, "master"),
|
||||
new Question
|
||||
{
|
||||
@@ -154,6 +157,7 @@ public sealed class QuestionBankEndpointTests
|
||||
var questionId = Guid.NewGuid();
|
||||
await using var factory = new ApiTestFactory();
|
||||
await factory.SeedAsync(
|
||||
PlatformTenant(),
|
||||
Tenant(tenantId, "master"),
|
||||
new Question
|
||||
{
|
||||
@@ -199,6 +203,16 @@ public sealed class QuestionBankEndpointTests
|
||||
};
|
||||
}
|
||||
|
||||
private static Tenant PlatformTenant() => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Slug = $"platform-{Guid.NewGuid():N}",
|
||||
Name = "Platform Question Bank",
|
||||
Status = TenantStatus.Active,
|
||||
Mode = TenantMode.PlatformOwned,
|
||||
Metadata = JsonDefaults.Object()
|
||||
};
|
||||
|
||||
private static async Task<JsonElement[]> ReadItemsAsync(HttpResponseMessage response)
|
||||
{
|
||||
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
|
||||
Reference in New Issue
Block a user