feat: strengthen P0 security and operations

This commit is contained in:
2026-08-01 11:20:02 +08:00
parent f776056834
commit 84c2b0b21d
77 changed files with 24185 additions and 355 deletions

View File

@@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using Npgsql;
using System.Text.Json;
using Tiku.Application.Commerce;
using Tiku.Application.Assets;
using Tiku.Application.PlatformBilling;
using Tiku.Application.Auth;
using Tiku.Application.Growth;
@@ -36,6 +37,7 @@ public sealed class ApiTestFactory(
IDomainOwnershipVerifier? domainOwnershipVerifier = null,
IDomainGatewayProvisioner? domainGatewayProvisioner = null,
ISmsProvider? smsProvider = null,
IAssetSecurityScanner? assetSecurityScanner = null,
IReadOnlyDictionary<string, string?>? configurationOverrides = null,
DbCommandInterceptor? dbCommandInterceptor = null) : WebApplicationFactory<ApiProgramMarker>
{
@@ -56,7 +58,6 @@ public sealed class ApiTestFactory(
{
var values = new Dictionary<string, string?>
{
["BackgroundProcessing:Enabled"] = "false",
["Security:Jwt:KeyId"] = TestJwtKeys.KeyId,
["Security:Jwt:PrivateKeyPem"] = TestJwtKeys.PrivateKeyPem,
["Tenancy:Resolution:TenantCodePathPrefixes:0"] = "/api"
@@ -143,6 +144,12 @@ public sealed class ApiTestFactory(
services.RemoveAll<ISmsProvider>();
services.AddSingleton(smsProvider);
}
if (assetSecurityScanner is not null)
{
services.RemoveAll<IAssetSecurityScanner>();
services.AddSingleton(assetSecurityScanner);
}
});
}

View File

@@ -177,6 +177,29 @@ public sealed class AssetAccessEndpointTests
Assert.Equal("asset_preview_not_supported", body.RootElement.GetProperty("code").GetString());
}
[Theory]
[InlineData(AssetSecurityScanStatus.Pending)]
[InlineData(AssetSecurityScanStatus.Scanning)]
[InlineData(AssetSecurityScanStatus.Failed)]
[InlineData(AssetSecurityScanStatus.Skipped)]
public async Task Asset_access_fails_closed_until_security_scan_is_trusted(
AssetSecurityScanStatus scanStatus)
{
var tenantId = Guid.NewGuid();
var assetId = Guid.NewGuid();
await using var factory = new ApiTestFactory(objectStorageService: new FakeObjectStorageService());
var asset = PublicAsset(tenantId, assetId);
asset.SecurityScanStatus = scanStatus;
await factory.SeedAsync(Tenant(tenantId, "scan-gate"), asset);
using var client = factory.CreateClient();
using var response = await client.GetAsync($"/api/assets/{assetId}/download?tenantCode=scan-gate");
var body = await ReadJsonAsync(response);
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
Assert.Equal("asset_security_scan_not_passed", body.RootElement.GetProperty("code").GetString());
}
private static ContentAsset PublicAsset(Guid tenantId, Guid assetId)
{
return new ContentAsset

View File

@@ -128,6 +128,11 @@ public sealed class AssetManagementEndpointTests
Assert.Equal(seed.UserId, asset.VerifiedBy);
Assert.Equal(2048, asset.VerifiedSizeBytes);
Assert.Equal(new string('a', 64), asset.VerifiedChecksumSha256);
var scanJob = Assert.Single(dbContext.BackgroundJobs.Where(job =>
job.TenantId == seed.TenantId && job.JobType == "asset_security_scan"));
Assert.Equal(AssetSecurityScanStatus.Pending, asset.SecurityScanStatus);
Assert.Equal(assetId, scanJob.Payload.GetProperty("assetId").GetGuid());
Assert.StartsWith($"asset:{assetId:N}:", scanJob.IdempotencyKey, StringComparison.Ordinal);
Assert.False(dbContext.TenantFeatureUsages.Any(value =>
value.TenantId == seed.TenantId && value.MetricCode == SaasQuotaMetricCatalog.StorageBytes));
}

View File

@@ -60,6 +60,9 @@ public sealed class AuthPasswordLifecycleTests
[InlineData(nameof(AuthController.LoginWithWechatWeb), "oauth/wechat")]
[InlineData(nameof(AuthController.LoginWithWechatMiniApp), "oauth/wechat-miniapp")]
[InlineData(nameof(AuthController.ChangeRequiredPassword), "password/change-required")]
[InlineData(nameof(AuthController.SendPasswordResetCode), "password/reset/sms/send")]
[InlineData(nameof(AuthController.ResetPassword), "password/reset")]
[InlineData(nameof(AuthController.ChangePassword), "password/change")]
[InlineData(nameof(AuthController.Refresh), "refresh")]
[InlineData(nameof(AuthController.Logout), "logout")]
[InlineData(nameof(AuthController.LogoutAll), "logout-all")]

View File

@@ -0,0 +1,289 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Api.Contracts;
using Tiku.Application.Auth;
using Tiku.Domain.Identity;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
public sealed class AuthRecoveryAndDeviceEndpointTests
{
[Fact]
public async Task Password_reset_send_does_not_reveal_account_existence()
{
var provider = new CapturingSmsProvider();
await using var factory = new ApiTestFactory(smsProvider: provider);
var seed = await SeedUserAsync(factory);
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Add("x-tenant-code", seed.TenantId.ToString("N"));
var existing = await client.PostAsJsonAsync(
"/api/auth/password/reset/sms/send",
new PasswordResetSmsSendDto
{
TenantCode = seed.TenantId.ToString("N"),
Phone = seed.Phone,
DeviceId = "known-device"
});
var missing = await client.PostAsJsonAsync(
"/api/auth/password/reset/sms/send",
new PasswordResetSmsSendDto
{
TenantCode = seed.TenantId.ToString("N"),
Phone = "13999999999",
DeviceId = "unknown-device"
});
Assert.Equal(HttpStatusCode.Accepted, existing.StatusCode);
Assert.Equal(HttpStatusCode.Accepted, missing.StatusCode);
Assert.Equal(1, provider.SendCount);
Assert.DoesNotContain(provider.Code!, await existing.Content.ReadAsStringAsync(), StringComparison.Ordinal);
Assert.DoesNotContain(provider.Code!, await missing.Content.ReadAsStringAsync(), StringComparison.Ordinal);
}
[Fact]
public async Task Password_reset_consumes_reset_code_and_revokes_existing_sessions()
{
var provider = new CapturingSmsProvider();
await using var factory = new ApiTestFactory(smsProvider: provider);
var seed = await SeedUserAsync(factory);
using var client = factory.CreateClient();
var oldTokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone);
var send = await client.PostAsJsonAsync(
"/api/auth/password/reset/sms/send",
new PasswordResetSmsSendDto
{
TenantCode = seed.TenantId.ToString("N"),
Phone = seed.Phone,
DeviceId = "reset-device"
});
Assert.Equal(HttpStatusCode.Accepted, send.StatusCode);
Assert.NotNull(provider.Code);
var resetRequest = new PasswordResetDto
{
TenantCode = seed.TenantId.ToString("N"),
Phone = seed.Phone,
Code = provider.Code!,
NewPassword = "ResetPassword2026"
};
var reset = await client.PostAsJsonAsync("/api/auth/password/reset", resetRequest);
Assert.Equal(HttpStatusCode.NoContent, reset.StatusCode);
client.UseAccessToken(oldTokens);
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/me")).StatusCode);
Assert.Equal(
HttpStatusCode.Unauthorized,
(await PostPasswordLoginAsync(client, seed, PasswordTestUserExtensions.TestPassword)).StatusCode);
Assert.Equal(
HttpStatusCode.OK,
(await PostPasswordLoginAsync(client, seed, resetRequest.NewPassword)).StatusCode);
Assert.Equal(
HttpStatusCode.Unauthorized,
(await client.PostAsJsonAsync("/api/auth/password/reset", resetRequest)).StatusCode);
}
[Fact]
public async Task Authenticated_password_change_rotates_to_a_new_session()
{
await using var factory = new ApiTestFactory();
var seed = await SeedUserAsync(factory);
using var client = factory.CreateClient();
var oldTokens = await client.LoginAsTenantAsync(seed.TenantId, seed.Phone);
client.UseAccessToken(oldTokens);
var changed = await client.PostAsJsonAsync(
"/api/auth/password/change",
new AuthenticatedPasswordChangeDto
{
CurrentPassword = PasswordTestUserExtensions.TestPassword,
NewPassword = "ChangedPassword2026"
});
Assert.Equal(HttpStatusCode.OK, changed.StatusCode);
using var body = JsonDocument.Parse(await changed.Content.ReadAsStringAsync());
var accessToken = body.RootElement.GetProperty("user").GetProperty("tokens").GetProperty("accessToken").GetString();
var refreshToken = body.RootElement.GetProperty("user").GetProperty("tokens").GetProperty("refreshToken").GetString();
Assert.False(string.IsNullOrWhiteSpace(accessToken));
Assert.False(string.IsNullOrWhiteSpace(refreshToken));
client.UseAccessToken(oldTokens);
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/me")).StatusCode);
client.DefaultRequestHeaders.Authorization = new("Bearer", accessToken);
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/api/me")).StatusCode);
Assert.Equal(
HttpStatusCode.Unauthorized,
(await PostPasswordLoginAsync(client, seed, PasswordTestUserExtensions.TestPassword)).StatusCode);
Assert.Equal(
HttpStatusCode.OK,
(await PostPasswordLoginAsync(client, seed, "ChangedPassword2026")).StatusCode);
}
[Fact]
public async Task Device_sessions_are_scoped_and_only_other_owned_families_can_be_revoked()
{
await using var factory = new ApiTestFactory();
var seed = await SeedUserAsync(factory);
var other = await SeedUserAsync(factory);
using var firstClient = factory.CreateClient();
using var secondClient = factory.CreateClient();
using var otherClient = factory.CreateClient();
_ = await firstClient.LoginAsTenantAsync(seed.TenantId, seed.Phone);
var secondTokens = await secondClient.LoginAsTenantAsync(seed.TenantId, seed.Phone);
var otherTokens = await otherClient.LoginAsTenantAsync(other.TenantId, other.Phone);
secondClient.UseAccessToken(secondTokens);
otherClient.UseAccessToken(otherTokens);
using var sessions = JsonDocument.Parse(await (await secondClient.GetAsync("/api/me/sessions")).Content.ReadAsStringAsync());
var items = sessions.RootElement.EnumerateArray().ToArray();
Assert.Equal(2, items.Length);
var currentFamily = items.Single(item => item.GetProperty("isCurrent").GetBoolean())
.GetProperty("sessionFamilyId").GetGuid();
var otherOwnedFamily = items.Single(item => !item.GetProperty("isCurrent").GetBoolean())
.GetProperty("sessionFamilyId").GetGuid();
Assert.Equal(
HttpStatusCode.Conflict,
(await secondClient.DeleteAsync($"/api/me/sessions/{currentFamily}")).StatusCode);
Assert.Equal(
HttpStatusCode.NotFound,
(await otherClient.DeleteAsync($"/api/me/sessions/{otherOwnedFamily}")).StatusCode);
Assert.Equal(
HttpStatusCode.NoContent,
(await secondClient.DeleteAsync($"/api/me/sessions/{otherOwnedFamily}")).StatusCode);
using var remaining = JsonDocument.Parse(await (await secondClient.GetAsync("/api/me/sessions")).Content.ReadAsStringAsync());
Assert.Single(remaining.RootElement.EnumerateArray());
}
[Fact]
public async Task Tenant_administrator_reset_requires_same_tenant_and_forces_password_change()
{
await using var factory = new ApiTestFactory();
var admin = await SeedUserAsync(factory);
var target = await SeedMemberAsync(factory, admin.TenantId, TenantRole.Teacher);
var crossTenantTarget = await SeedUserAsync(factory);
using var targetClient = factory.CreateClient();
var targetTokens = await targetClient.LoginAsTenantAsync(target.TenantId, target.Phone);
using var adminClient = factory.CreateClient();
adminClient.UseAccessToken(await adminClient.LoginAsTenantAsync(admin.TenantId, admin.Phone));
var reset = await adminClient.PostAsJsonAsync(
$"/api/tenant-admin/members/{target.UserId}/password-reset",
new AdministrativePasswordResetDto
{
TemporaryPassword = "TemporaryPassword2026",
Reason = "Account recovery verification"
});
var crossTenant = await adminClient.PostAsJsonAsync(
$"/api/tenant-admin/members/{crossTenantTarget.UserId}/password-reset",
new AdministrativePasswordResetDto
{
TemporaryPassword = "TemporaryPassword2026",
Reason = "Must not cross tenant boundary"
});
Assert.Equal(HttpStatusCode.NoContent, reset.StatusCode);
Assert.Equal(HttpStatusCode.NotFound, crossTenant.StatusCode);
targetClient.UseAccessToken(targetTokens);
Assert.Equal(HttpStatusCode.Unauthorized, (await targetClient.GetAsync("/api/me")).StatusCode);
var temporaryLogin = await PostPasswordLoginAsync(targetClient, target, "TemporaryPassword2026");
Assert.Equal(HttpStatusCode.OK, temporaryLogin.StatusCode);
Assert.Contains("password_change_required", await temporaryLogin.Content.ReadAsStringAsync(), StringComparison.Ordinal);
using var scope = factory.CreateSystemScope("Verify administrative password reset");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.True(await dbContext.Users.Where(item => item.Id == target.UserId).Select(item => item.ForcePasswordChange).SingleAsync());
Assert.True(await dbContext.AuditLogs.AnyAsync(item =>
item.TenantId == admin.TenantId &&
item.ActorUserId == admin.UserId &&
item.Action == "auth.password.reset_by_administrator" &&
item.TargetId == target.UserId.ToString()));
}
private static Task<HttpResponseMessage> PostPasswordLoginAsync(
HttpClient client,
UserSeed seed,
string password) =>
client.PostAsJsonAsync(
"/api/auth/login/password",
new PasswordLoginDto
{
Realm = AuthRealm.Tenant,
TenantCode = seed.TenantId.ToString("N"),
Identifier = seed.Phone,
Password = password
});
private static async Task<UserSeed> SeedUserAsync(ApiTestFactory factory)
{
var tenantId = Guid.NewGuid();
var user = new User
{
Id = Guid.NewGuid(),
Phone = $"13{Random.Shared.NextInt64(100_000_000, 1_000_000_000)}",
Name = "Recovery test user"
}.WithTestPassword();
await factory.SeedAsync(
new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "Recovery test tenant",
Status = TenantStatus.Active
},
user,
new TenantMembership
{
TenantId = tenantId,
UserId = user.Id,
Role = TenantRole.TenantAdmin,
Status = MembershipStatus.Active
});
return new UserSeed(tenantId, user.Id, user.Phone!);
}
private static async Task<UserSeed> SeedMemberAsync(
ApiTestFactory factory,
Guid tenantId,
TenantRole role)
{
var user = new User
{
Id = Guid.NewGuid(),
Phone = $"13{Random.Shared.NextInt64(100_000_000, 1_000_000_000)}",
Name = "Administrative reset target"
}.WithTestPassword();
await factory.SeedAsync(
user,
new TenantMembership
{
TenantId = tenantId,
UserId = user.Id,
Role = role,
Status = MembershipStatus.Active
});
return new UserSeed(tenantId, user.Id, user.Phone!);
}
private sealed record UserSeed(Guid TenantId, Guid UserId, string Phone);
private sealed class CapturingSmsProvider : ISmsProvider
{
public int SendCount { get; private set; }
public string? Code { get; private set; }
public Task<SmsProviderSendResult> SendAsync(
SmsProviderSendRequest request,
CancellationToken cancellationToken = default)
{
SendCount++;
Code = request.Code;
return Task.FromResult(new SmsProviderSendResult("test", "sent", "reset-message-id"));
}
}
}

View File

@@ -14,8 +14,8 @@ namespace Tiku.IntegrationTests.Api;
public sealed class AuthorizationManifestTests
{
private const int ExpectedActionCount = 399;
private const string ExpectedSha256 = "e4460d18dbd88cb8a4293c650688f03ddaa67e69a423e70d6675c635e237c316";
private const int ExpectedActionCount = 426;
private const string ExpectedSha256 = "e45f159b7285342e44f256b63c483c575f9b624f01e5ad9f96c4bfea71603bb7";
[Fact]
public void Controller_authorization_surface_matches_reviewed_manifest()

View File

@@ -3,8 +3,6 @@ using System.Net;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Tiku.Api.BackgroundProcessing;
using Tiku.Application.Jobs;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
@@ -14,20 +12,8 @@ namespace Tiku.IntegrationTests.Api;
public sealed class MonolithBackgroundProcessingTests
{
private static readonly IReadOnlyDictionary<string, string?> EnabledConfiguration =
new Dictionary<string, string?>
{
["BackgroundProcessing:Enabled"] = "true",
["BackgroundProcessing:JobPollSeconds"] = "1",
["BackgroundProcessing:JobParallelism"] = "2",
["BackgroundProcessing:JobBatchSize"] = "2",
["TenantDomains:Enabled"] = "false",
["SaasSubscriptions:Enabled"] = "false",
["FeatureUsageReconciliation:Enabled"] = "false"
};
[Fact]
public async Task Readiness_reports_only_postgres_and_redis_dependencies()
public async Task Anonymous_readiness_is_minimal_and_does_not_expose_dependency_topology()
{
await using var factory = new ApiTestFactory();
using var client = factory.CreateClient();
@@ -36,81 +22,26 @@ public sealed class MonolithBackgroundProcessingTests
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.True(document.RootElement.TryGetProperty("database", out _));
Assert.True(document.RootElement.TryGetProperty("redis", out _));
Assert.Equal("ready", document.RootElement.GetProperty("status").GetString());
Assert.False(document.RootElement.TryGetProperty("database", out _));
Assert.False(document.RootElement.TryGetProperty("redis", out _));
Assert.False(document.RootElement.TryGetProperty("rabbitMq", out _));
Assert.False(document.RootElement.TryGetProperty("outbox", out _));
}
[Fact]
public async Task Background_processing_registration_obeys_master_switch()
public async Task Api_host_does_not_register_background_processors()
{
await using var disabledFactory = new ApiTestFactory();
using var disabledClient = disabledFactory.CreateClient();
var disabledNames = disabledFactory.Services.GetServices<IHostedService>()
.Select(service => service.GetType().Name)
.ToArray();
Assert.False(disabledFactory.Services.GetRequiredService<IOptions<BackgroundProcessingOptions>>().Value.Enabled);
Assert.Contains("TenantDomainBackgroundService", disabledNames);
Assert.Contains("SaasSubscriptionBackgroundService", disabledNames);
Assert.Contains("FeatureUsageBackgroundService", disabledNames);
Assert.Contains("BackgroundJobsBackgroundService", disabledNames);
await using var enabledFactory = new ApiTestFactory(configurationOverrides: EnabledConfiguration);
using var enabledClient = enabledFactory.CreateClient();
var enabledNames = enabledFactory.Services.GetServices<IHostedService>()
.Select(service => service.GetType().Name)
.ToArray();
Assert.True(enabledFactory.Services.GetRequiredService<IOptions<BackgroundProcessingOptions>>().Value.Enabled);
Assert.Contains("TenantDomainBackgroundService", enabledNames);
Assert.Contains("SaasSubscriptionBackgroundService", enabledNames);
Assert.Contains("FeatureUsageBackgroundService", enabledNames);
Assert.Contains("BackgroundJobsBackgroundService", enabledNames);
}
[Fact]
public async Task Api_host_processes_immediate_and_due_postgres_jobs()
{
await using var factory = new ApiTestFactory(configurationOverrides: EnabledConfiguration);
await using var factory = new ApiTestFactory();
using var client = factory.CreateClient();
var tenantId = Guid.NewGuid();
await factory.SeedAsync(new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "Monolith Background Processing"
});
var hostedServiceNames = factory.Services.GetServices<IHostedService>()
.Select(service => service.GetType().Name)
.ToArray();
BackgroundJobItem immediate;
BackgroundJobItem delayed;
using (var scope = factory.CreateSystemScope("Queue monolith background jobs"))
{
var jobs = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
immediate = await jobs.EnqueueAsync(new CreateBackgroundJobCommand(
tenantId,
"statistics_aggregation",
JsonSerializer.SerializeToElement(new { scope = "tenant" })));
delayed = await jobs.EnqueueAsync(new CreateBackgroundJobCommand(
tenantId,
"statistics_aggregation",
JsonSerializer.SerializeToElement(new { scope = "tenant" }),
DateTimeOffset.UtcNow.AddMinutes(5)));
}
Assert.Equal(BackgroundJobStatus.Succeeded, await WaitForStatusAsync(factory, immediate.Id));
Assert.Equal(BackgroundJobStatus.Pending, await ReadStatusAsync(factory, delayed.Id));
using (var scope = factory.CreateSystemScope("Make delayed monolith job due"))
{
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
await dbContext.BackgroundJobs
.Where(job => job.Id == delayed.Id)
.ExecuteUpdateAsync(setters => setters.SetProperty(job => job.RunAfter, DateTimeOffset.UtcNow.AddSeconds(-1)));
}
Assert.Equal(BackgroundJobStatus.Succeeded, await WaitForStatusAsync(factory, delayed.Id));
Assert.DoesNotContain("TenantDomainWorker", hostedServiceNames);
Assert.DoesNotContain("SaasSubscriptionWorker", hostedServiceNames);
Assert.DoesNotContain("FeatureUsageWorker", hostedServiceNames);
Assert.DoesNotContain("BackgroundJobsWorker", hostedServiceNames);
}
[Fact]
@@ -163,23 +94,6 @@ public sealed class MonolithBackgroundProcessingTests
.ProcessPendingAsync(workerId, 1);
}
private static async Task<BackgroundJobStatus> WaitForStatusAsync(ApiTestFactory factory, Guid jobId)
{
var timeout = DateTimeOffset.UtcNow.AddSeconds(10);
while (DateTimeOffset.UtcNow < timeout)
{
var status = await ReadStatusAsync(factory, jobId);
if (status is BackgroundJobStatus.Succeeded or BackgroundJobStatus.Failed)
{
return status;
}
await Task.Delay(100);
}
return await ReadStatusAsync(factory, jobId);
}
private static async Task<BackgroundJobStatus> ReadStatusAsync(ApiTestFactory factory, Guid jobId)
{
using var scope = factory.CreateSystemScope("Read monolith background job status");

View File

@@ -0,0 +1,171 @@
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Assets;
using Tiku.Application.Jobs;
using Tiku.Application.Storage;
using Tiku.Domain.Content;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
public sealed class P0AssetSecurityScanTests
{
[Theory]
[InlineData(AssetSecurityScanVerdict.Clean, AssetSecurityScanStatus.Passed, AssetSecurityRiskLevel.None)]
[InlineData(AssetSecurityScanVerdict.Infected, AssetSecurityScanStatus.Failed, AssetSecurityRiskLevel.Critical)]
public async Task Asset_scan_persists_terminal_verdict_and_audit_event(
AssetSecurityScanVerdict verdict,
AssetSecurityScanStatus expectedStatus,
AssetSecurityRiskLevel expectedRisk)
{
var scanner = new FakeScanner(new AssetSecurityScanResult(
verdict,
"clamav",
verdict == AssetSecurityScanVerdict.Infected ? "Eicar-Signature" : null,
12,
verdict == AssetSecurityScanVerdict.Infected ? "stream: Eicar-Signature FOUND" : "stream: OK"));
await using var factory = new ApiTestFactory(
objectStorageService: new ReadableStorage(),
assetSecurityScanner: scanner);
var (tenantId, assetId, jobId) = await SeedScanAsync(factory);
using (var scope = factory.CreateSystemScope("Process asset security scan"))
{
Assert.Equal(1, await scope.ServiceProvider.GetRequiredService<IBackgroundJobService>()
.ProcessPendingAsync("asset-scan-test", 10));
}
using var verifyScope = factory.CreateSystemScope("Verify asset security scan");
var dbContext = verifyScope.ServiceProvider.GetRequiredService<TikuDbContext>();
var asset = await dbContext.ContentAssets.AsNoTracking().SingleAsync(item => item.Id == assetId);
var job = await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == jobId);
var scanEvent = await dbContext.ContentAssetSecurityScanEvents.AsNoTracking()
.SingleAsync(item => item.TenantId == tenantId && item.AssetId == assetId);
Assert.Equal(expectedStatus, asset.SecurityScanStatus);
Assert.Equal("clamav", asset.SecurityScanProvider);
Assert.NotNull(asset.SecurityScannedAt);
Assert.Equal(BackgroundJobStatus.Succeeded, job.Status);
Assert.Equal(expectedStatus, scanEvent.ScanStatus);
Assert.Equal(expectedRisk, scanEvent.RiskLevel);
if (verdict == AssetSecurityScanVerdict.Infected)
{
Assert.Contains("Eicar-Signature", scanEvent.IssueCodes);
}
else
{
Assert.Empty(scanEvent.IssueCodes);
}
}
[Fact]
public async Task Unavailable_scanner_keeps_asset_pending_and_schedules_job_retry()
{
await using var factory = new ApiTestFactory(
objectStorageService: new ReadableStorage(),
assetSecurityScanner: new FakeScanner(new AssetSecurityScannerException(
"clamav_unavailable",
"ClamAV is unavailable.")));
var (tenantId, assetId, jobId) = await SeedScanAsync(factory);
using (var scope = factory.CreateSystemScope("Process unavailable asset security scan"))
{
Assert.Equal(1, await scope.ServiceProvider.GetRequiredService<IBackgroundJobService>()
.ProcessPendingAsync("asset-scan-test", 10));
}
using var verifyScope = factory.CreateSystemScope("Verify asset security scan retry");
var dbContext = verifyScope.ServiceProvider.GetRequiredService<TikuDbContext>();
var asset = await dbContext.ContentAssets.AsNoTracking().SingleAsync(item => item.Id == assetId);
var job = await dbContext.BackgroundJobs.AsNoTracking().SingleAsync(item => item.Id == jobId);
var scanEvent = await dbContext.ContentAssetSecurityScanEvents.AsNoTracking()
.SingleAsync(item => item.TenantId == tenantId && item.AssetId == assetId);
Assert.Equal(AssetSecurityScanStatus.Pending, asset.SecurityScanStatus);
Assert.Equal(BackgroundJobStatus.Pending, job.Status);
Assert.Equal(1, job.RetryCount);
Assert.NotNull(job.RunAfter);
Assert.Contains("clamav_unavailable", job.LastError);
Assert.Equal(AssetSecurityScanStatus.Pending, scanEvent.ScanStatus);
Assert.Contains("clamav_unavailable", scanEvent.IssueCodes);
}
private static async Task<(Guid TenantId, Guid AssetId, Guid JobId)> SeedScanAsync(ApiTestFactory factory)
{
var tenantId = Guid.NewGuid();
var assetId = Guid.NewGuid();
await factory.SeedAsync(
new Tenant
{
Id = tenantId,
Slug = tenantId.ToString("N"),
Name = "Asset scan tenant",
Status = TenantStatus.Active
},
new ContentAsset
{
Id = assetId,
TenantId = tenantId,
Title = "Scannable asset",
FileName = "scan.txt",
StorageProvider = AssetStorageProvider.LocalDev,
Bucket = "tenant-assets",
ObjectKey = $"{tenantId:N}/assets/scan.txt",
MimeType = "text/plain",
FileSizeBytes = 12,
VerifiedSizeBytes = 12,
UploadStatus = AssetUploadStatus.Verified,
SecurityScanStatus = AssetSecurityScanStatus.Pending
});
using var scope = factory.CreateSystemScope("Queue asset security scan");
var job = await scope.ServiceProvider.GetRequiredService<IBackgroundJobService>().EnqueueAsync(
new CreateBackgroundJobCommand(
tenantId,
"asset_security_scan",
JsonSerializer.SerializeToElement(new { assetId }),
MaxRetries: 5,
IdempotencyKey: $"asset:{assetId:N}:integration-test",
IsSystemJob: true));
return (tenantId, assetId, job.Id);
}
private sealed class FakeScanner(object outcome) : IAssetSecurityScanner
{
public Task<AssetSecurityScanResult> ScanAsync(
Stream content,
long? declaredLength,
CancellationToken cancellationToken = default) =>
outcome switch
{
AssetSecurityScanResult result => Task.FromResult(result),
Exception exception => Task.FromException<AssetSecurityScanResult>(exception),
_ => throw new InvalidOperationException("Unsupported scanner outcome.")
};
public Task<bool> CheckHealthAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(outcome is AssetSecurityScanResult);
}
private sealed class ReadableStorage : IObjectStorageService
{
public string ConfiguredDefaultProvider() => ObjectStorageProviders.LocalDev;
public string ConfiguredDefaultBucket() => "tenant-assets";
public string NormalizeProvider(string? value, string? fallback = null) => value ?? fallback ?? ObjectStorageProviders.LocalDev;
public string ValidateObjectKey(Guid tenantId, string objectKey) => objectKey;
public string ValidateMimeType(string mimeType) => mimeType;
public long? ValidateFileSize(long? fileSizeBytes) => fileSizeBytes;
public void AssertUploadProvider(string provider) { }
public void AssertWritableLocation(StorageAssetLocation location) { }
public Task<ObjectStorageSignedUrl> SignUploadAsync(ObjectStorageUploadSignRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<ObjectStorageSignedUrl> SignDownloadAsync(ObjectStorageDownloadSignRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<ObjectStorageWriteResult> WriteObjectAsync(ObjectStorageWriteRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<ObjectStorageMetadata> HeadObjectAsync(ObjectStorageHeadRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task<Stream> OpenReadAsync(ObjectStorageReadRequest request, CancellationToken cancellationToken = default) =>
Task.FromResult<Stream>(new MemoryStream(Encoding.UTF8.GetBytes("hello world!")));
}
}

View File

@@ -0,0 +1,136 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Jobs;
using Tiku.Application.Tenancy;
using Tiku.Domain.Identity;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests.Api;
public sealed class P0OperationsLifecycleTests
{
[Fact]
public async Task Job_idempotency_cancel_retry_and_tenant_scope_follow_the_reviewed_state_machine()
{
await using var factory = new ApiTestFactory();
var tenantA = await SeedTenantAsync(factory);
var tenantB = await SeedTenantAsync(factory);
using var scope = factory.CreateSystemScope("Verify P0 job state machine");
var jobs = scope.ServiceProvider.GetRequiredService<IBackgroundJobService>();
var command = new CreateBackgroundJobCommand(
tenantA.TenantId,
"asset_security_scan",
JsonSerializer.SerializeToElement(new { assetId = Guid.NewGuid() }),
IdempotencyKey: "same-request",
IsSystemJob: true);
var first = await jobs.EnqueueAsync(command);
var duplicate = await jobs.EnqueueAsync(command);
Assert.Equal(first.Id, duplicate.Id);
Assert.Null(await jobs.GetAsync(first.Id, tenantB.TenantId));
var cancelled = await jobs.RequestCancellationAsync(
first.Id, tenantA.TenantId, tenantA.UserId, "No longer required");
Assert.Equal(BackgroundJobStatus.Cancelled, cancelled.Status);
Assert.NotNull(cancelled.CancellationRequestedAt);
var retried = await jobs.RetryAsync(first.Id, tenantA.TenantId, tenantA.UserId);
Assert.Equal(BackgroundJobStatus.Pending, retried.Status);
Assert.Null(retried.CancellationRequestedAt);
await Assert.ThrowsAsync<BackgroundJobException>(() =>
jobs.RetryAsync(first.Id, tenantA.TenantId, tenantA.UserId));
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
await dbContext.BackgroundJobs.Where(item => item.Id == first.Id)
.ExecuteUpdateAsync(setters => setters
.SetProperty(item => item.Status, BackgroundJobStatus.Processing)
.SetProperty(item => item.LockedBy, "active-worker")
.SetProperty(item => item.LockExpiresAt, DateTimeOffset.UtcNow.AddMinutes(5)));
var cooperative = await jobs.RequestCancellationAsync(
first.Id, tenantA.TenantId, tenantA.UserId, "Stop at the next cooperative boundary");
Assert.Equal(BackgroundJobStatus.Processing, cooperative.Status);
Assert.NotNull(cooperative.CancellationRequestedAt);
}
[Fact]
public async Task Tenant_export_archive_restore_and_owner_transfer_preserve_lifecycle_invariants()
{
await using var factory = new ApiTestFactory(
configurationOverrides: new Dictionary<string, string?>
{
["Storage:DefaultProvider"] = "local_dev",
["Storage:DefaultBucket"] = "tenant-assets"
});
var seed = await SeedTenantAsync(factory, includeSecondMember: true);
Guid exportOperationId;
using (var scope = factory.CreateSystemScope("Create tenant export operation"))
{
var lifecycle = scope.ServiceProvider.GetRequiredService<ITenantLifecycleService>();
var blocked = await lifecycle.PreviewArchiveAsync(seed.TenantId);
Assert.False(blocked.CanArchive);
Assert.Contains("recent_successful_export_required", blocked.Blockers);
exportOperationId = (await lifecycle.CreateExportAsync(seed.TenantId, seed.UserId)).Id;
}
using (var scope = factory.CreateSystemScope("Process tenant export operation"))
{
var processed = await scope.ServiceProvider.GetRequiredService<IBackgroundJobService>()
.ProcessPendingAsync("tenant-export-test", 10);
Assert.Equal(1, processed);
}
using (var scope = factory.CreateSystemScope("Archive restore and transfer tenant"))
{
var lifecycle = scope.ServiceProvider.GetRequiredService<ITenantLifecycleService>();
var export = await lifecycle.GetOperationAsync(seed.TenantId, exportOperationId);
var exportJob = await scope.ServiceProvider.GetRequiredService<TikuDbContext>().BackgroundJobs.AsNoTracking()
.SingleAsync(item => item.TenantId == seed.TenantId && item.JobType == "tenant_export");
Assert.True(
export!.Status == TenantLifecycleOperationStatus.Succeeded,
$"Export status={export.Status}, operationError={export.LastError}, jobStatus={exportJob.Status}, jobError={exportJob.LastError}");
Assert.NotNull(export.ExportAssetId);
Assert.True((await lifecycle.PreviewArchiveAsync(seed.TenantId)).CanArchive);
await lifecycle.ArchiveAsync(seed.TenantId, seed.UserId, "Contract ended");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.Equal(TenantStatus.Archived, await dbContext.Tenants.Where(item => item.Id == seed.TenantId).Select(item => item.Status).SingleAsync());
Assert.All(await dbContext.TenantDomains.Where(item => item.TenantId == seed.TenantId).Select(item => item.Status).ToArrayAsync(),
status => Assert.Equal(TenantDomainStatus.Disabled, status));
await lifecycle.RestoreAsync(seed.TenantId, seed.UserId, "Customer returned");
Assert.Equal(TenantStatus.Suspended, await dbContext.Tenants.Where(item => item.Id == seed.TenantId).Select(item => item.Status).SingleAsync());
Assert.All(await dbContext.TenantDomains.Where(item => item.TenantId == seed.TenantId).Select(item => item.Status).ToArrayAsync(),
status => Assert.Equal(TenantDomainStatus.Pending, status));
await lifecycle.TransferOwnerAsync(seed.TenantId, seed.UserId, seed.SecondUserId!.Value, "Ownership handover");
Assert.Equal(seed.SecondUserId, await dbContext.Tenants.Where(item => item.Id == seed.TenantId).Select(item => item.OwnerUserId).SingleAsync());
Assert.Equal(TenantRole.TenantAdmin, await dbContext.TenantMemberships.Where(item => item.TenantId == seed.TenantId && item.UserId == seed.UserId).Select(item => item.Role).SingleAsync());
Assert.Equal(TenantRole.TenantOwner, await dbContext.TenantMemberships.Where(item => item.TenantId == seed.TenantId && item.UserId == seed.SecondUserId).Select(item => item.Role).SingleAsync());
}
}
private static async Task<TenantSeed> SeedTenantAsync(ApiTestFactory factory, bool includeSecondMember = false)
{
var tenantId = Guid.NewGuid();
var userId = Guid.NewGuid();
var secondUserId = includeSecondMember ? Guid.NewGuid() : (Guid?)null;
var entities = new List<object>
{
new Tenant { Id = tenantId, Slug = tenantId.ToString("N"), Name = "Lifecycle tenant", OwnerUserId = userId },
new User { Id = userId, Phone = $"13{Random.Shared.NextInt64(100_000_000, 1_000_000_000)}", Name = "Owner" }.WithTestPassword(),
new TenantMembership { TenantId = tenantId, UserId = userId, Role = TenantRole.TenantOwner, Status = MembershipStatus.Active },
new TenantDomain { TenantId = tenantId, Host = $"{tenantId:N}.example.test", Status = TenantDomainStatus.Active, IsPrimary = true }
};
if (secondUserId.HasValue)
{
entities.Add(new User { Id = secondUserId.Value, Phone = $"13{Random.Shared.NextInt64(100_000_000, 1_000_000_000)}", Name = "Next owner" }.WithTestPassword());
entities.Add(new TenantMembership { TenantId = tenantId, UserId = secondUserId.Value, Role = TenantRole.TenantAdmin, Status = MembershipStatus.Active });
}
await factory.SeedAsync(entities.ToArray());
return new TenantSeed(tenantId, userId, secondUserId);
}
private sealed record TenantSeed(Guid TenantId, Guid UserId, Guid? SecondUserId);
}

View File

@@ -19,6 +19,53 @@ namespace Tiku.IntegrationTests.Api;
public sealed class PlatformAdminEndpointTests
{
[Fact]
public async Task Platform_staff_password_reset_revokes_sessions_and_requires_change()
{
await using var factory = new ApiTestFactory(configurationOverrides: new Dictionary<string, string?>
{
["Tenancy:Resolution:PlatformHosts:0"] = "localhost"
});
var administrator = await SeedPlatformAdminAsync(factory);
var target = await SeedAdditionalPlatformUserAsync(factory);
using var targetClient = factory.CreateClient();
var targetTokens = await targetClient.LoginAsPlatformAsync(target.Email);
using var adminClient = factory.CreateClient();
adminClient.UseAccessToken(await adminClient.LoginAsPlatformAsync(administrator.Email));
var reset = await adminClient.PostAsJsonAsync(
$"/api/platform-admin/staff/{target.UserId}/password-reset",
new AdministrativePasswordResetDto
{
TemporaryPassword = "TemporaryPassword2026",
Reason = "Platform staff recovery verification"
});
Assert.Equal(HttpStatusCode.NoContent, reset.StatusCode);
targetClient.UseAccessToken(targetTokens);
Assert.Equal(HttpStatusCode.Unauthorized, (await targetClient.GetAsync("/api/me")).StatusCode);
targetClient.DefaultRequestHeaders.Authorization = null;
var login = await targetClient.PostAsJsonAsync(
"/api/auth/login/password",
new PasswordLoginDto
{
Realm = AuthRealm.Platform,
Identifier = target.Email,
Password = "TemporaryPassword2026"
});
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
Assert.Contains("password_change_required", await login.Content.ReadAsStringAsync(), StringComparison.Ordinal);
using var scope = factory.CreateSystemScope("Verify platform administrative password reset");
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
Assert.True(await dbContext.Users.Where(item => item.Id == target.UserId).Select(item => item.ForcePasswordChange).SingleAsync());
Assert.True(await dbContext.AuditLogs.AnyAsync(item =>
item.TenantId == null &&
item.ActorUserId == administrator.UserId &&
item.Action == "auth.password.reset_by_administrator" &&
item.TargetId == target.UserId.ToString()));
}
[Fact]
public async Task Platform_super_admin_can_load_every_platform_console_bootstrap_endpoint()
{
@@ -506,6 +553,43 @@ public sealed class PlatformAdminEndpointTests
return await SeedPlatformUserAsync(factory, BackendPermissions.Platform);
}
private static async Task<(Guid UserId, string Email)> SeedAdditionalPlatformUserAsync(ApiTestFactory factory)
{
var userId = Guid.NewGuid();
var roleId = Guid.NewGuid();
var email = $"platform-target-{Guid.NewGuid():N}@example.test";
await factory.SeedAsync(
new User
{
Id = userId,
Email = email,
NormalizedEmail = email.ToUpperInvariant(),
UserName = email,
NormalizedUserName = email.ToUpperInvariant(),
Name = "Platform Reset Target",
PrimaryRole = "platform_staff",
RawProfile = JsonDefaults.Object()
}.WithTestPassword(),
new PlatformBackendRole
{
Id = roleId,
Code = $"platform_reset_target_{roleId:N}",
Name = "Platform Reset Target",
Status = BackendRoleStatus.Active
},
new PlatformBackendRolePermission
{
RoleId = roleId,
PermissionCode = BackendPermissions.PlatformDashboardView
},
new PlatformBackendUserRole
{
UserId = userId,
RoleId = roleId
});
return (userId, email);
}
private static async Task<(Guid UserId, string Email)> SeedPlatformUserAsync(
ApiTestFactory factory,
IEnumerable<string> platformPermissions)

View File

@@ -23,13 +23,20 @@ public sealed class BuiltinBackofficeCatalogSeederTests
await seeder.SeedAsync();
Assert.Equal(15, await dbContext.SaasFeatures.CountAsync());
Assert.Equal(26, await dbContext.PermissionModules.CountAsync());
Assert.Equal(35, await dbContext.BackendPermissions.CountAsync());
Assert.Equal(27, await dbContext.PermissionModules.CountAsync());
Assert.Equal(37, await dbContext.BackendPermissions.CountAsync());
Assert.Equal(21, await dbContext.BackendMenus.CountAsync());
Assert.Equal(15, await dbContext.SaasFeatures.Select(item => item.Code).Distinct().CountAsync());
Assert.Equal(26, await dbContext.PermissionModules.Select(item => item.Code).Distinct().CountAsync());
Assert.Equal(35, await dbContext.BackendPermissions.Select(item => item.Code).Distinct().CountAsync());
Assert.Equal(27, await dbContext.PermissionModules.Select(item => item.Code).Distinct().CountAsync());
Assert.Equal(37, await dbContext.BackendPermissions.Select(item => item.Code).Distinct().CountAsync());
Assert.Equal(21, await dbContext.BackendMenus.Select(item => item.Code).Distinct().CountAsync());
Assert.True(await dbContext.PermissionModules.AnyAsync(item => item.Code == "platform_operations"));
Assert.True(await dbContext.BackendPermissions.AnyAsync(item =>
item.Code == BackendPermissions.PlatformOperationsView &&
item.PermissionModuleCode == "platform_operations"));
Assert.True(await dbContext.BackendPermissions.AnyAsync(item =>
item.Code == BackendPermissions.PlatformOperationsManage &&
item.PermissionModuleCode == "platform_operations"));
}
[Fact]
@@ -82,8 +89,8 @@ public sealed class BuiltinBackofficeCatalogSeederTests
Assert.Equal("Custom menu title", (await dbContext.BackendMenus.SingleAsync(
item => item.Code == "tenant.dashboard")).Title);
Assert.Equal(15, await dbContext.SaasFeatures.CountAsync());
Assert.Equal(26, await dbContext.PermissionModules.CountAsync());
Assert.Equal(35, await dbContext.BackendPermissions.CountAsync());
Assert.Equal(27, await dbContext.PermissionModules.CountAsync());
Assert.Equal(37, await dbContext.BackendPermissions.CountAsync());
Assert.Equal(21, await dbContext.BackendMenus.CountAsync());
Assert.False(await dbContext.PermissionModules.AnyAsync(module =>
module.RequiredFeatureCode != null &&