feat: strengthen P0 security and operations
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
289
Tiku.IntegrationTests/Api/AuthRecoveryAndDeviceEndpointTests.cs
Normal file
289
Tiku.IntegrationTests/Api/AuthRecoveryAndDeviceEndpointTests.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
|
||||
171
Tiku.IntegrationTests/Api/P0AssetSecurityScanTests.cs
Normal file
171
Tiku.IntegrationTests/Api/P0AssetSecurityScanTests.cs
Normal 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!")));
|
||||
}
|
||||
}
|
||||
136
Tiku.IntegrationTests/Api/P0OperationsLifecycleTests.cs
Normal file
136
Tiku.IntegrationTests/Api/P0OperationsLifecycleTests.cs
Normal 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);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user