feat(security): complete capability messaging workflows

This commit is contained in:
2026-07-29 11:21:15 +08:00
parent df88fa19cb
commit 5c4de4b282
35 changed files with 19544 additions and 89 deletions

View File

@@ -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();

View File

@@ -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;

View File

@@ -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,

View File

@@ -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]

View File

@@ -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());

View File

@@ -4,11 +4,16 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using StackExchange.Redis;
using System.Diagnostics;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Sockets;
using System.Text.Json;
using Tiku.Application;
using Tiku.Application.Jobs;
using Tiku.Contracts;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure;
using Tiku.Infrastructure.Messaging;
using Tiku.Infrastructure.Persistence;
@@ -17,6 +22,199 @@ namespace Tiku.IntegrationTests;
public sealed class MassTransitOutboxTests
{
[Fact]
public async Task Bus_outbox_drains_after_real_broker_restart()
{
var rabbitMqHost = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ");
var containerName = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_CONTAINER");
if (string.IsNullOrWhiteSpace(rabbitMqHost) ||
Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ_RESTART") != "1" ||
string.IsNullOrWhiteSpace(containerName))
{
return;
}
await using var factory = CreateRabbitFactory(rabbitMqHost);
using var client = factory.CreateClient();
Assert.True(await WaitForReadyAsync(client), "API dependencies did not become ready before restart drill.");
await RunDockerAsync("stop", containerName);
try
{
Assert.True(await WaitForBrokerPortClosedAsync(new Uri(rabbitMqHost)),
"RabbitMQ AMQP port remained reachable after stopping the test container.");
using (var scope = factory.CreateSystemScope("Commit outbox while RabbitMQ is stopped"))
{
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var publisher = scope.ServiceProvider.GetRequiredService<ISecurityEventPublisher>();
await using var transaction = await dbContext.Database.BeginTransactionAsync();
await publisher.AuthorizationChangedAsync(
null, null, "broker_restart_test", 3, Guid.NewGuid().ToString("N"));
await dbContext.SaveChangesAsync();
await transaction.CommitAsync();
}
using var verification = factory.CreateSystemScope("Verify restart outbox backlog");
var verificationDbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.NotEmpty(await verificationDbContext.Set<OutboxMessage>().ToArrayAsync());
}
finally
{
await RunDockerAsync("start", containerName);
}
Assert.True(await WaitForReadyAsync(client, 240),
"RabbitMQ did not become ready within 60 seconds after restart.");
var drained = false;
for (var attempt = 0; attempt < 240; attempt++)
{
using var verification = factory.CreateSystemScope("Wait for post-restart outbox drain");
var dbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
if (!await dbContext.Set<OutboxMessage>().AnyAsync())
{
drained = true;
break;
}
await Task.Delay(250);
}
Assert.True(drained, "Bus outbox did not drain within 60 seconds after RabbitMQ restart.");
}
private static async Task RunDockerAsync(string operation, string containerName)
{
using var process = Process.Start(new ProcessStartInfo
{
FileName = "docker",
ArgumentList = { operation, containerName },
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
}) ?? throw new InvalidOperationException("Failed to start Docker CLI for RabbitMQ restart drill.");
var standardOutput = await process.StandardOutput.ReadToEndAsync();
var standardError = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
$"docker {operation} failed for the RabbitMQ test container: {standardError}{standardOutput}");
}
}
private static async Task<bool> WaitForBrokerPortClosedAsync(Uri broker)
{
var port = broker.IsDefaultPort ? 5672 : broker.Port;
for (var attempt = 0; attempt < 40; attempt++)
{
using var tcpClient = new TcpClient();
try
{
await tcpClient.ConnectAsync(broker.Host, port).WaitAsync(TimeSpan.FromMilliseconds(250));
}
catch (Exception exception) when (exception is SocketException or TimeoutException)
{
return true;
}
await Task.Delay(250);
}
return false;
}
[Fact]
public async Task Background_job_request_is_transactional_consumed_once_and_keeps_database_status_view()
{
var rabbitMqHost = Environment.GetEnvironmentVariable("TIKU_TEST_RABBITMQ");
if (string.IsNullOrWhiteSpace(rabbitMqHost))
{
return;
}
await using var factory = CreateRabbitFactory(rabbitMqHost);
using var client = factory.CreateClient();
Assert.True(await WaitForReadyAsync(client), "API dependencies did not become ready within 10 seconds.");
var tenantId = Guid.NewGuid();
await factory.SeedAsync(new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "RabbitMQ Background Job Tenant"
});
var workerBuilder = Host.CreateApplicationBuilder();
workerBuilder.Services.AddApplication();
workerBuilder.Services.AddInfrastructure(factory.DatabaseConnectionString);
workerBuilder.Services.AddReliableMessaging(CreateRabbitOptions(rabbitMqHost, configureConsumers: true));
using var worker = workerBuilder.Build();
await worker.StartAsync();
Guid rolledBackJobId;
using (var scope = factory.CreateSystemScope("Roll back background job request"))
{
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var jobs = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
await using var transaction = await dbContext.Database.BeginTransactionAsync();
var job = await jobs.EnqueueAsync(new CreateBackgroundJobCommand(
tenantId, "tenant_domain_recheck", JsonSerializer.SerializeToElement(new { })));
rolledBackJobId = job.Id;
await transaction.RollbackAsync();
}
using (var verification = factory.CreateSystemScope("Verify rolled back background job request"))
{
var dbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.False(await dbContext.BackgroundJobs.AnyAsync(item => item.Id == rolledBackJobId));
Assert.False(await dbContext.Set<OutboxMessage>().AnyAsync(
item => item.MessageId == rolledBackJobId));
}
BackgroundJobItem committedJob;
using (var scope = factory.CreateSystemScope("Commit background job request"))
{
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
var jobs = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
await using var transaction = await dbContext.Database.BeginTransactionAsync();
committedJob = await jobs.EnqueueAsync(new CreateBackgroundJobCommand(
tenantId, "tenant_domain_recheck", JsonSerializer.SerializeToElement(new { })));
await transaction.CommitAsync();
}
BackgroundJobStatus? status = null;
for (var attempt = 0; attempt < 60; attempt++)
{
using var verification = factory.CreateSystemScope("Wait for RabbitMQ background job consumer");
var dbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
status = await dbContext.BackgroundJobs
.Where(item => item.Id == committedJob.Id)
.Select(item => (BackgroundJobStatus?)item.Status)
.SingleAsync();
if (status == BackgroundJobStatus.Succeeded) break;
await Task.Delay(250);
}
Assert.Equal(BackgroundJobStatus.Succeeded, status);
using (var verification = factory.CreateSystemScope("Verify background job inbox and status view"))
{
var dbContext = verification.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.Single(await dbContext.Set<InboxState>()
.Where(item => item.MessageId == committedJob.Id)
.ToArrayAsync());
var jobs = verification.ServiceProvider.GetRequiredService<IBackgroundJobService>();
var statusView = await jobs.ListAsync(tenantId, "tenant_domain_recheck");
Assert.Contains(statusView, item =>
item.Id == committedJob.Id && item.Status == BackgroundJobStatus.Succeeded);
}
var managementEndpoint = ResolveRabbitManagementEndpoint(rabbitMqHost);
if (managementEndpoint is not null)
{
Assert.Equal(0, await GetQueueMessageCountAsync(
managementEndpoint, "background-job-requested_error"));
}
await worker.StopAsync();
}
[Fact]
public async Task Worker_consumer_uses_inbox_and_updates_non_authoritative_redis_version()
{
@@ -177,9 +375,9 @@ public sealed class MassTransitOutboxTests
ConfigureConsumers = configureConsumers
};
private static async Task<bool> WaitForReadyAsync(HttpClient client)
private static async Task<bool> WaitForReadyAsync(HttpClient client, int attempts = 40)
{
for (var attempt = 0; attempt < 40; attempt++)
for (var attempt = 0; attempt < attempts; attempt++)
{
if ((await client.GetAsync("/api/health/ready")).StatusCode == HttpStatusCode.OK)
{
@@ -210,6 +408,10 @@ public sealed class MassTransitOutboxTests
var credentials = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{username}:{password}"));
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials);
using var response = await client.GetAsync($"/api/queues/%2F/{Uri.EscapeDataString(queueName)}");
if (response.StatusCode == HttpStatusCode.NotFound)
{
return 0;
}
response.EnsureSuccessStatusCode();
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
return document.RootElement.GetProperty("messages").GetInt32();

View File

@@ -16,10 +16,20 @@ public sealed class SystemScopeAuditTests
await Assert.ThrowsAsync<ArgumentException>(() => executionScope.ExecuteAsync(
new SystemScopeRequest(null, SystemScopeCallerType.Worker, "worker", "", "job-1"),
(_, _) => Task.CompletedTask));
await Assert.ThrowsAsync<ArgumentException>(() => executionScope.ExecuteAsync(
new SystemScopeRequest(null, SystemScopeCallerType.Worker, "worker", "missing target", "job-2"),
(_, _) => Task.CompletedTask));
var correlationId = Guid.NewGuid().ToString("N");
var tenantId = Guid.NewGuid();
await factory.SeedAsync(new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "System Scope Tenant"
});
await executionScope.ExecuteAsync(
new SystemScopeRequest(null, SystemScopeCallerType.Worker, "background-worker", "test audit", correlationId),
new SystemScopeRequest(tenantId, SystemScopeCallerType.Worker, "background-worker", "test audit", correlationId),
(_, _) => Task.CompletedTask);
using var verification = factory.CreateSystemScope("Verify execution scope audit");