diff --git a/src/Host/FSH.Starter.Api/Dockerfile b/src/Host/FSH.Starter.Api/Dockerfile index e4f65cd076..3a2a354d5d 100644 --- a/src/Host/FSH.Starter.Api/Dockerfile +++ b/src/Host/FSH.Starter.Api/Dockerfile @@ -20,7 +20,11 @@ RUN dotnet publish src/Host/FSH.Starter.Api/FSH.Starter.Api.csproj \ # ── Runtime stage ────────────────────────────────────────────────── # Chiseled = no shell, no apt, no debugger. Smaller pull + smaller CVE # surface. APP_UID (1654) is the non-root user baked into the image. -FROM mcr.microsoft.com/dotnet/nightly/aspnet:10.0-noble-chiseled AS runtime +# This Dockerfile (docker compose) stays chiseled on purpose. The csproj +# noble (SDK container publish, used by +# the AWS deploy) is full noble because chiseled strips libgssapi_krb5 and +# Npgsql's GSSAPI probe then logs a noisy (benign) load error in CloudWatch. +FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS runtime WORKDIR /app COPY --from=build /publish . diff --git a/src/Host/FSH.Starter.Api/appsettings.Production.json b/src/Host/FSH.Starter.Api/appsettings.Production.json index 1f26741901..6c40734bbd 100644 --- a/src/Host/FSH.Starter.Api/appsettings.Production.json +++ b/src/Host/FSH.Starter.Api/appsettings.Production.json @@ -82,11 +82,13 @@ }, "MailOptions": { "From": "", - "Host": "", - "Port": 0, - "UserName": "", - "Password": "", - "DisplayName": "" + "DisplayName": "", + "Smtp": { + "Host": "", + "Port": 587, + "UserName": "", + "Password": "" + } }, "RateLimitingOptions": { "Enabled": true, diff --git a/src/Host/FSH.Starter.DbMigrator/Dockerfile b/src/Host/FSH.Starter.DbMigrator/Dockerfile index 7919c4abf6..d0d963929e 100644 --- a/src/Host/FSH.Starter.DbMigrator/Dockerfile +++ b/src/Host/FSH.Starter.DbMigrator/Dockerfile @@ -15,7 +15,10 @@ RUN dotnet publish src/Host/FSH.Starter.DbMigrator/FSH.Starter.DbMigrator.csproj --no-restore \ -o /publish -FROM mcr.microsoft.com/dotnet/nightly/aspnet:10.0-noble-chiseled AS runtime +# Chiseled on purpose (compose). The csproj ContainerFamily (SDK publish, AWS) +# is full noble: chiseled strips libgssapi_krb5, and Npgsql's GSSAPI probe +# then logs a noisy (benign) load error. See the API Dockerfile. +FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS runtime WORKDIR /app COPY --from=build /publish . diff --git a/src/Tests/Integration.Tests/Infrastructure/TenantProvisioningWait.cs b/src/Tests/Integration.Tests/Infrastructure/TenantProvisioningWait.cs new file mode 100644 index 0000000000..a5525a669e --- /dev/null +++ b/src/Tests/Integration.Tests/Infrastructure/TenantProvisioningWait.cs @@ -0,0 +1,46 @@ +using FSH.Modules.Multitenancy.Contracts.Dtos; + +namespace Integration.Tests.Infrastructure; + +/// +/// Polls a tenant's provisioning status until the whole run finishes. +/// +/// The status body also lists each step, and a finished step reads "Completed" while later steps +/// (seeding the tenant admin) are still running. Matching "Completed" anywhere in the body returns +/// after the first step, so only the overall Status field is trusted. +/// +internal static class TenantProvisioningWait +{ + public static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) + { + ArgumentNullException.ThrowIfNull(client); + + TenantProvisioningStatusDto? status = null; + for (int i = 0; i < maxRetries; i++) + { + var statusResponse = await client.GetAsync( + $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); + + if (statusResponse.IsSuccessStatusCode) + { + status = await statusResponse.Content.ReadFromJsonAsync(); + if (string.Equals(status?.Status, "Completed", StringComparison.Ordinal)) + { + return; + } + + if (string.Equals(status?.Status, "Failed", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Tenant {tenantId} provisioning failed at {status?.CurrentStep}: {status?.Error}"); + } + } + + await Task.Delay(1000); + } + + throw new TimeoutException( + $"Tenant {tenantId} provisioning did not complete within {maxRetries} seconds. " + + $"Last status: {status?.Status ?? ""} (step {status?.CurrentStep ?? ""})."); + } +} diff --git a/src/Tests/Integration.Tests/Tests/Auditing/AuditQueryTenantIsolationTests.cs b/src/Tests/Integration.Tests/Tests/Auditing/AuditQueryTenantIsolationTests.cs index e6e8f2c091..6c4cdeb2b9 100644 --- a/src/Tests/Integration.Tests/Tests/Auditing/AuditQueryTenantIsolationTests.cs +++ b/src/Tests/Integration.Tests/Tests/Auditing/AuditQueryTenantIsolationTests.cs @@ -29,7 +29,7 @@ public async Task ByIdCorrelationTrace_Should_NotLeakAcrossTenants() var otherAdminEmail = $"audit-key-admin-{uniqueId}@tenant.com"; await CreateTenantAsync(rootClient, otherTenantId, otherAdminEmail); - await WaitForProvisioningAsync(rootClient, otherTenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, otherTenantId); using var otherClient = await CreateTenantAdminClientWithRetryAsync( otherAdminEmail, TestConstants.DefaultPassword, otherTenantId); @@ -105,33 +105,4 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant var body = await response.Content.ReadAsStringAsync(); response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); } - - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (int i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"Tenant {tenantId} provisioning failed: {content}"); - } - } - - await Task.Delay(1000); - } - - throw new TimeoutException( - $"Tenant {tenantId} provisioning did not complete within {maxRetries} seconds."); - } } diff --git a/src/Tests/Integration.Tests/Tests/Auditing/AuditTenantIsolationTests.cs b/src/Tests/Integration.Tests/Tests/Auditing/AuditTenantIsolationTests.cs index 52751e8cc0..1cd80b1130 100644 --- a/src/Tests/Integration.Tests/Tests/Auditing/AuditTenantIsolationTests.cs +++ b/src/Tests/Integration.Tests/Tests/Auditing/AuditTenantIsolationTests.cs @@ -21,7 +21,7 @@ public async Task GetAudits_Should_OnlyReturnCurrentTenantsRecords() var otherAdminEmail = $"audit-admin-{uniqueId}@tenant.com"; await CreateTenantAsync(rootClient, otherTenantId, otherAdminEmail); - await WaitForProvisioningAsync(rootClient, otherTenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, otherTenantId); // Authenticating as both tenants triggers audit events on both sides. _ = await _auth.GetRootAdminTokenAsync(); @@ -81,39 +81,4 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant var body = await response.Content.ReadAsStringAsync(); response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); } - - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (int i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"Tenant {tenantId} provisioning failed: {content}"); - } - } - - await Task.Delay(1000); - } - - var finalResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - var finalContent = finalResponse.IsSuccessStatusCode - ? await finalResponse.Content.ReadAsStringAsync() - : $"HTTP {finalResponse.StatusCode}"; - - throw new TimeoutException( - $"Tenant {tenantId} provisioning did not complete within {maxRetries} seconds. Last status: {finalContent}"); - } } diff --git a/src/Tests/Integration.Tests/Tests/Billing/BillingTenantIsolationTests.cs b/src/Tests/Integration.Tests/Tests/Billing/BillingTenantIsolationTests.cs index 614e852eb3..dbeb63b424 100644 --- a/src/Tests/Integration.Tests/Tests/Billing/BillingTenantIsolationTests.cs +++ b/src/Tests/Integration.Tests/Tests/Billing/BillingTenantIsolationTests.cs @@ -65,7 +65,7 @@ public async Task GetInvoiceById_Should_Return404_When_OwnedByDifferentTenant() var otherAdminEmail = $"billing-inv-admin-{uniqueId}@tenant.com"; await CreateTenantAsync(rootClient, otherTenantId, otherAdminEmail); - await WaitForProvisioningAsync(rootClient, otherTenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, otherTenantId); using var otherClient = await CreateTenantAdminClientWithRetryAsync( otherAdminEmail, TestConstants.DefaultPassword, otherTenantId); @@ -108,7 +108,7 @@ public async Task GetSubscription_Should_NotLeak_When_OtherTenantPassesOwnersTen var otherAdminEmail = $"billing-sub-admin-{uniqueId}@tenant.com"; await CreateTenantAsync(rootClient, otherTenantId, otherAdminEmail); - await WaitForProvisioningAsync(rootClient, otherTenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, otherTenantId); using var otherClient = await CreateTenantAdminClientWithRetryAsync( otherAdminEmail, TestConstants.DefaultPassword, otherTenantId); @@ -427,7 +427,7 @@ private async Task CreateTenantAdminClientWithRetryAsync( var tenantId = $"billing-{label}-iso-{uniqueId}"; var adminEmail = $"billing-{label}-admin-{uniqueId}@tenant.com"; await CreateTenantAsync(rootClient, tenantId, adminEmail); - await WaitForProvisioningAsync(rootClient, tenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, tenantId); var client = await CreateTenantAdminClientWithRetryAsync(adminEmail, TestConstants.DefaultPassword, tenantId); return (client, tenantId); } @@ -458,40 +458,5 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); } - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (int i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"Tenant {tenantId} provisioning failed: {content}"); - } - } - - await Task.Delay(1000); - } - - var finalResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - var finalContent = finalResponse.IsSuccessStatusCode - ? await finalResponse.Content.ReadAsStringAsync() - : $"HTTP {finalResponse.StatusCode}"; - - throw new TimeoutException( - $"Tenant {tenantId} provisioning did not complete within {maxRetries} seconds. Last status: {finalContent}"); - } - #endregion } diff --git a/src/Tests/Integration.Tests/Tests/Billing/InvoicePdfTests.cs b/src/Tests/Integration.Tests/Tests/Billing/InvoicePdfTests.cs index ede5332ffd..119cdb5774 100644 --- a/src/Tests/Integration.Tests/Tests/Billing/InvoicePdfTests.cs +++ b/src/Tests/Integration.Tests/Tests/Billing/InvoicePdfTests.cs @@ -33,7 +33,7 @@ public async Task InvoicePdf_Should_Be_Downloadable_By_Owner_And_RootOperator_Bu var adminEmail = $"pdf-{unique}@tenant.com"; var planKey = await CreatePlanAsync(rootClient, $"pdf-m-{unique}", 29m); await CreateTenantAsync(rootClient, tenantId, adminEmail, planKey); - await WaitForProvisioningAsync(rootClient, tenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, tenantId); // TenantSubscribed goes through the outbox now, so the invoice is issued on the next // dispatch cycle rather than inside the create request. @@ -62,7 +62,7 @@ public async Task InvoicePdf_Should_Be_Downloadable_By_Owner_And_RootOperator_Bu var otherTenantId = $"pdf-other-{otherUnique}"; var otherEmail = $"pdf-other-{otherUnique}@tenant.com"; await CreateTenantAsync(rootClient, otherTenantId, otherEmail, planKey); - await WaitForProvisioningAsync(rootClient, otherTenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, otherTenantId); using var otherClient = await CreateTenantAdminClientWithRetryAsync( otherEmail, TestConstants.DefaultPassword, otherTenantId); @@ -121,26 +121,4 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant var body = await response.Content.ReadAsStringAsync(); response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); } - - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (var i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync($"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException($"Tenant {tenantId} provisioning failed: {content}"); - } - } - await Task.Delay(1000); - } - throw new TimeoutException($"Tenant {tenantId} did not finish provisioning."); - } } diff --git a/src/Tests/Integration.Tests/Tests/Billing/TopupApprovalTests.cs b/src/Tests/Integration.Tests/Tests/Billing/TopupApprovalTests.cs index 9f72fccc3c..1ae6cebe06 100644 --- a/src/Tests/Integration.Tests/Tests/Billing/TopupApprovalTests.cs +++ b/src/Tests/Integration.Tests/Tests/Billing/TopupApprovalTests.cs @@ -147,7 +147,7 @@ public async Task NonRoot_cannot_see_other_tenants_requests() var tenantBId = $"topup-iso-{uniqueId}"; var tenantBAdminEmail = $"topup-admin-{uniqueId}@tenant.com"; await CreateTenantAsync(rootClient, tenantBId, tenantBAdminEmail); - await WaitForProvisioningAsync(rootClient, tenantBId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, tenantBId); using var tenantBClient = await CreateTenantAdminClientWithRetryAsync( tenantBAdminEmail, TestConstants.DefaultPassword, tenantBId); @@ -256,28 +256,6 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); } - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (int i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - return; - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException($"Tenant {tenantId} provisioning failed: {content}"); - } - - await Task.Delay(1000); - } - - throw new TimeoutException($"Tenant {tenantId} provisioning did not complete within {maxRetries}s."); - } - private async Task CreateTenantAdminClientWithRetryAsync( string email, string password, string tenant, int maxRetries = 30) { diff --git a/src/Tests/Integration.Tests/Tests/Billing/WalletEndpointsTests.cs b/src/Tests/Integration.Tests/Tests/Billing/WalletEndpointsTests.cs index 87ba8a29b3..54bc8de1f2 100644 --- a/src/Tests/Integration.Tests/Tests/Billing/WalletEndpointsTests.cs +++ b/src/Tests/Integration.Tests/Tests/Billing/WalletEndpointsTests.cs @@ -159,7 +159,7 @@ public async Task GetMyTopupRequests_Must_NotLeak_Requests_From_Other_Tenant() var otherAdminEmail = $"wallet-admin-{uniqueId}@tenant.com"; await CreateTenantAsync(rootClient, otherTenantId, otherAdminEmail); - await WaitForProvisioningAsync(rootClient, otherTenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, otherTenantId); using var otherClient = await CreateTenantAdminClientWithRetryAsync( otherAdminEmail, TestConstants.DefaultPassword, otherTenantId); @@ -212,41 +212,6 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); } - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (int i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"Tenant {tenantId} provisioning failed: {content}"); - } - } - - await Task.Delay(1000); - } - - var finalResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - var finalContent = finalResponse.IsSuccessStatusCode - ? await finalResponse.Content.ReadAsStringAsync() - : $"HTTP {finalResponse.StatusCode}"; - - throw new TimeoutException( - $"Tenant {tenantId} provisioning did not complete within {maxRetries} seconds. Last status: {finalContent}"); - } - private async Task CreateTenantAdminClientWithRetryAsync( string email, string password, string tenant, int maxRetries = 30) { diff --git a/src/Tests/Integration.Tests/Tests/Catalog/CatalogTenantIsolationTests.cs b/src/Tests/Integration.Tests/Tests/Catalog/CatalogTenantIsolationTests.cs index 1dc0475564..c678c2fa01 100644 --- a/src/Tests/Integration.Tests/Tests/Catalog/CatalogTenantIsolationTests.cs +++ b/src/Tests/Integration.Tests/Tests/Catalog/CatalogTenantIsolationTests.cs @@ -145,7 +145,7 @@ private async Task ProvisionTenantClientAsync(HttpClient rootClient, { var adminEmail = $"{tenantId}-admin@tenant.com"; await CreateTenantAsync(rootClient, tenantId, adminEmail); - await WaitForProvisioningAsync(rootClient, tenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, tenantId); return await CreateTenantAdminClientWithRetryAsync( adminEmail, TestConstants.DefaultPassword, tenantId); } @@ -182,39 +182,4 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant var body = await response.Content.ReadAsStringAsync(); response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); } - - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (int i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"Tenant {tenantId} provisioning failed: {content}"); - } - } - - await Task.Delay(1000); - } - - var finalResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - var finalContent = finalResponse.IsSuccessStatusCode - ? await finalResponse.Content.ReadAsStringAsync() - : $"HTTP {finalResponse.StatusCode}"; - - throw new TimeoutException( - $"Tenant {tenantId} provisioning did not complete within {maxRetries} seconds. Last status: {finalContent}"); - } } diff --git a/src/Tests/Integration.Tests/Tests/Chat/ChatTenantIsolationTests.cs b/src/Tests/Integration.Tests/Tests/Chat/ChatTenantIsolationTests.cs index e06715062f..934bee1cdb 100644 --- a/src/Tests/Integration.Tests/Tests/Chat/ChatTenantIsolationTests.cs +++ b/src/Tests/Integration.Tests/Tests/Chat/ChatTenantIsolationTests.cs @@ -231,7 +231,7 @@ private async Task CreateProvisionedTenantAdminClientAsync() using var rootClient = await _auth.CreateRootAdminClientAsync(); await CreateTenantAsync(rootClient, tenantId, adminEmail); - await WaitForProvisioningAsync(rootClient, tenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, tenantId); return await CreateTenantAdminClientWithRetryAsync(adminEmail, TestConstants.DefaultPassword, tenantId); } @@ -267,39 +267,4 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant var body = await response.Content.ReadAsStringAsync(); response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); } - - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (int i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"Tenant {tenantId} provisioning failed: {content}"); - } - } - - await Task.Delay(1000); - } - - var finalResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - var finalContent = finalResponse.IsSuccessStatusCode - ? await finalResponse.Content.ReadAsStringAsync() - : $"HTTP {finalResponse.StatusCode}"; - - throw new TimeoutException( - $"Tenant {tenantId} provisioning did not complete within {maxRetries} seconds. Last status: {finalContent}"); - } } diff --git a/src/Tests/Integration.Tests/Tests/Files/FileTenantIsolationTests.cs b/src/Tests/Integration.Tests/Tests/Files/FileTenantIsolationTests.cs index 45838b56f1..87ee4e59a6 100644 --- a/src/Tests/Integration.Tests/Tests/Files/FileTenantIsolationTests.cs +++ b/src/Tests/Integration.Tests/Tests/Files/FileTenantIsolationTests.cs @@ -92,7 +92,7 @@ private async Task CreateOtherTenantAdminClientAsync() var otherAdminEmail = $"file-admin-{uniqueId}@tenant.com"; await CreateTenantAsync(rootClient, otherTenantId, otherAdminEmail); - await WaitForProvisioningAsync(rootClient, otherTenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, otherTenantId); return await CreateTenantAdminClientWithRetryAsync( otherAdminEmail, TestConstants.DefaultPassword, otherTenantId); } @@ -130,41 +130,6 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); } - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (int i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"Tenant {tenantId} provisioning failed: {content}"); - } - } - - await Task.Delay(1000); - } - - var finalResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - var finalContent = finalResponse.IsSuccessStatusCode - ? await finalResponse.Content.ReadAsStringAsync() - : $"HTTP {finalResponse.StatusCode}"; - - throw new TimeoutException( - $"Tenant {tenantId} provisioning did not complete within {maxRetries} seconds. Last status: {finalContent}"); - } - #endregion #region Upload helpers diff --git a/src/Tests/Integration.Tests/Tests/Groups/GroupTenantIsolationTests.cs b/src/Tests/Integration.Tests/Tests/Groups/GroupTenantIsolationTests.cs index 09a9980188..b78019c1ee 100644 --- a/src/Tests/Integration.Tests/Tests/Groups/GroupTenantIsolationTests.cs +++ b/src/Tests/Integration.Tests/Tests/Groups/GroupTenantIsolationTests.cs @@ -147,7 +147,7 @@ private async Task ProvisionTenantClientAsync(HttpClient rootClient, { var adminEmail = $"{tenantId}-admin@tenant.com"; await CreateTenantAsync(rootClient, tenantId, adminEmail); - await WaitForProvisioningAsync(rootClient, tenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, tenantId); return await CreateTenantAdminClientWithRetryAsync( adminEmail, TestConstants.DefaultPassword, tenantId); } @@ -184,39 +184,4 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant var body = await response.Content.ReadAsStringAsync(); response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); } - - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (int i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"Tenant {tenantId} provisioning failed: {content}"); - } - } - - await Task.Delay(1000); - } - - var finalResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - var finalContent = finalResponse.IsSuccessStatusCode - ? await finalResponse.Content.ReadAsStringAsync() - : $"HTTP {finalResponse.StatusCode}"; - - throw new TimeoutException( - $"Tenant {tenantId} provisioning did not complete within {maxRetries} seconds. Last status: {finalContent}"); - } } diff --git a/src/Tests/Integration.Tests/Tests/Impersonation/ImpersonationTests.cs b/src/Tests/Integration.Tests/Tests/Impersonation/ImpersonationTests.cs index 923abc5cfb..275e3c9f3b 100644 --- a/src/Tests/Integration.Tests/Tests/Impersonation/ImpersonationTests.cs +++ b/src/Tests/Integration.Tests/Tests/Impersonation/ImpersonationTests.cs @@ -58,7 +58,7 @@ public async Task InitializeAsync() using var rootClient = await _auth.CreateRootAdminClientAsync(); await CreateTenantAsync(rootClient, _tenantId, _tenantAdminEmail); - await WaitForProvisioningAsync(rootClient, _tenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, _tenantId); // Sign in as the seeded tenant admin to capture their userId from the JWT — the search // endpoint would couple these tests to the (currently buggy) cross-tenant search override. @@ -552,7 +552,7 @@ public async Task Revoke_Should_Return404_When_TenantAdmin_TargetsGrant_OutsideT using var rootClient = await _auth.CreateRootAdminClientAsync(); await CreateTenantAsync(rootClient, otherTenantId, otherAdminEmail); - await WaitForProvisioningAsync(rootClient, otherTenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, otherTenantId); var otherToken = await GetTokenWithRetryAsync(otherAdminEmail, TestConstants.DefaultPassword, otherTenantId); var otherAdminUserId = ReadSubject(otherToken.AccessToken); @@ -905,28 +905,6 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant response.StatusCode.ShouldBe(HttpStatusCode.Created); } - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (var i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync($"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException($"Tenant {tenantId} provisioning failed: {content}"); - } - } - await Task.Delay(1000); - } - throw new TimeoutException($"Tenant {tenantId} did not finish provisioning."); - } - // ─── shape mirrors ───────────────────────────────────────────────── private sealed class ImpersonationResponse diff --git a/src/Tests/Integration.Tests/Tests/Multitenancy/AdjustTenantValidityTests.cs b/src/Tests/Integration.Tests/Tests/Multitenancy/AdjustTenantValidityTests.cs index e16a33f422..47ad1fd43b 100644 --- a/src/Tests/Integration.Tests/Tests/Multitenancy/AdjustTenantValidityTests.cs +++ b/src/Tests/Integration.Tests/Tests/Multitenancy/AdjustTenantValidityTests.cs @@ -135,7 +135,7 @@ public async Task AdjustValidity_Should_Forbid_When_CallerIsTenantAdmin() var adminEmail = $"adj-authz-{unique}@tenant.com"; var planKey = await CreatePlanAsync(rootClient, $"adj-az-{unique}", monthlyBasePrice: 5m); await CreateTenantAsync(rootClient, tenantId, adminEmail, planKey); - await WaitForProvisioningAsync(rootClient, tenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, tenantId); using var tenantClient = await CreateTenantAdminClientWithRetryAsync( adminEmail, TestConstants.DefaultPassword, tenantId); @@ -207,28 +207,6 @@ private static async Task GetStatusAsync(HttpClient client, string return status; } - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (var i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync($"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException($"Tenant {tenantId} provisioning failed: {content}"); - } - } - await Task.Delay(1000); - } - throw new TimeoutException($"Tenant {tenantId} did not finish provisioning."); - } - private sealed record AdjustResult(string TenantId, DateTime ValidUpto); private sealed record TenantStatus diff --git a/src/Tests/Integration.Tests/Tests/Multitenancy/ChangeTenantActivationValidationTests.cs b/src/Tests/Integration.Tests/Tests/Multitenancy/ChangeTenantActivationValidationTests.cs index 11439e46c0..285afc217f 100644 --- a/src/Tests/Integration.Tests/Tests/Multitenancy/ChangeTenantActivationValidationTests.cs +++ b/src/Tests/Integration.Tests/Tests/Multitenancy/ChangeTenantActivationValidationTests.cs @@ -121,7 +121,7 @@ public async Task ChangeActivation_Should_Reactivate_When_ProvisioningCompleted( var unique = Guid.NewGuid().ToString("N")[..8]; var tenantId = $"act-reactivate-{unique}"; await CreateTenantAsync(rootClient, tenantId, $"act-reactivate-{unique}@tenant.com"); - await WaitForProvisioningAsync(rootClient, tenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, tenantId); await WaitForStableCompletedAsync(rootClient, tenantId); var deactivate = await rootClient.PostAsJsonAsync( @@ -196,28 +196,6 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); } - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (var i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync($"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException($"Tenant {tenantId} provisioning failed: {content}"); - } - } - await Task.Delay(1000); - } - throw new TimeoutException($"Tenant {tenantId} did not finish provisioning."); - } - /// /// Waits until the provisioning status reads "Completed" on several consecutive /// polls. Guards against the async-provisioning window where the latest record diff --git a/src/Tests/Integration.Tests/Tests/Multitenancy/MyTenantStatusTests.cs b/src/Tests/Integration.Tests/Tests/Multitenancy/MyTenantStatusTests.cs index 8bdca2661e..097a95702e 100644 --- a/src/Tests/Integration.Tests/Tests/Multitenancy/MyTenantStatusTests.cs +++ b/src/Tests/Integration.Tests/Tests/Multitenancy/MyTenantStatusTests.cs @@ -36,7 +36,7 @@ public async Task GetMyStatus_Should_Return_CallersOwn_Plan_And_ExpiryState() var adminEmail = $"mystatus-{unique}@tenant.com"; var planKey = await CreatePlanAsync(rootClient, $"mystatus-m-{unique}", 10m); await CreateTenantAsync(rootClient, tenantId, adminEmail, planKey); - await WaitForProvisioningAsync(rootClient, tenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, tenantId); using var tenantClient = await CreateTenantAdminClientWithRetryAsync(adminEmail, TestConstants.DefaultPassword, tenantId); @@ -102,28 +102,6 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); } - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (var i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync($"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException($"Tenant {tenantId} provisioning failed: {content}"); - } - } - await Task.Delay(1000); - } - throw new TimeoutException($"Tenant {tenantId} did not finish provisioning."); - } - private sealed record MyStatus { public string Id { get; init; } = string.Empty; diff --git a/src/Tests/Integration.Tests/Tests/Multitenancy/RenewTenantTests.cs b/src/Tests/Integration.Tests/Tests/Multitenancy/RenewTenantTests.cs index cb89d328dd..10628b19bc 100644 --- a/src/Tests/Integration.Tests/Tests/Multitenancy/RenewTenantTests.cs +++ b/src/Tests/Integration.Tests/Tests/Multitenancy/RenewTenantTests.cs @@ -140,7 +140,7 @@ public async Task RenewTenant_Should_Advance_SubscriptionEndUtc_On_SamePlanRenew var tenantId = $"renew-drift-{unique}"; var planKey = await CreatePlanAsync(rootClient, $"drift-m-{unique}", monthlyBasePrice: 10m); await CreateTenantAsync(rootClient, tenantId, $"renew-drift-{unique}@tenant.com", planKey); - await WaitForProvisioningAsync(rootClient, tenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, tenantId); // TenantSubscribed/TenantRenewed go through the outbox now, so Billing reacts on the next // dispatch cycle rather than inside the request. @@ -224,7 +224,7 @@ public async Task RenewTenant_Should_Forbid_When_CallerIsTenantAdmin() var adminEmail = $"renew-authz-{unique}@tenant.com"; var planKey = await CreatePlanAsync(rootClient, $"az-{unique}", monthlyBasePrice: 5m); await CreateTenantAsync(rootClient, tenantId, adminEmail, planKey); - await WaitForProvisioningAsync(rootClient, tenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, tenantId); using var tenantClient = await CreateTenantAdminClientWithRetryAsync( adminEmail, TestConstants.DefaultPassword, tenantId); @@ -309,28 +309,6 @@ private static async Task GetStatusAsync(HttpClient client, string return status; } - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (var i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync($"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException($"Tenant {tenantId} provisioning failed: {content}"); - } - } - await Task.Delay(1000); - } - throw new TimeoutException($"Tenant {tenantId} did not finish provisioning."); - } - private static async Task GetSubscriptionEndUtcAsync(HttpClient client, string tenantId) { var resp = await client.GetAsync($"{BillingBasePath}/subscriptions?tenantId={tenantId}"); diff --git a/src/Tests/Integration.Tests/Tests/Multitenancy/TenantHeaderOverrideTests.cs b/src/Tests/Integration.Tests/Tests/Multitenancy/TenantHeaderOverrideTests.cs index 1e5859d396..e0afd8069d 100644 --- a/src/Tests/Integration.Tests/Tests/Multitenancy/TenantHeaderOverrideTests.cs +++ b/src/Tests/Integration.Tests/Tests/Multitenancy/TenantHeaderOverrideTests.cs @@ -55,8 +55,8 @@ public async Task InitializeAsync() using var rootClient = await _auth.CreateRootAdminClientAsync(); await CreateTenantAsync(rootClient, _tenantA, _tenantAAdminEmail); await CreateTenantAsync(rootClient, _tenantB, _tenantBAdminEmail); - await WaitForProvisioningAsync(rootClient, _tenantA); - await WaitForProvisioningAsync(rootClient, _tenantB); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, _tenantA); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, _tenantB); // Provisioning can report "Completed" a tick before the seeded admin user is queryable; a // successful token issuance cross-checks the user exists, avoiding a first-test race on empty lists. @@ -209,28 +209,6 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant response.StatusCode.ShouldBe(HttpStatusCode.Created); } - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (var i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync($"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException($"Tenant {tenantId} provisioning failed: {content}"); - } - } - await Task.Delay(1000); - } - throw new TimeoutException($"Tenant {tenantId} did not finish provisioning."); - } - private sealed class SearchUserDto { public string? Id { get; set; } diff --git a/src/Tests/Integration.Tests/Tests/Multitenancy/TenantIsolationTests.cs b/src/Tests/Integration.Tests/Tests/Multitenancy/TenantIsolationTests.cs index 791d55cc5c..13c32a23c8 100644 --- a/src/Tests/Integration.Tests/Tests/Multitenancy/TenantIsolationTests.cs +++ b/src/Tests/Integration.Tests/Tests/Multitenancy/TenantIsolationTests.cs @@ -22,7 +22,7 @@ public async Task TenantIsolation_Should_NotLeakUsers_When_QueryingAcrossTenants var tenantId = $"iso-{uniqueId}"; var tenantAdminEmail = $"isoadmin-{uniqueId}@tenant.com"; await CreateTenantAsync(rootClient, tenantId, tenantAdminEmail); - await WaitForProvisioningAsync(rootClient, tenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, tenantId); // Login as new tenant admin — retry because provisioning may still be finalizing using var tenantClient = await CreateTenantAdminClientWithRetryAsync( @@ -64,8 +64,8 @@ public async Task TenantIsolation_Should_AllowSameEmailInDifferentTenants() await CreateTenantAsync(rootClient, tenant1Id, t1AdminEmail); await CreateTenantAsync(rootClient, tenant2Id, t2AdminEmail); - await WaitForProvisioningAsync(rootClient, tenant1Id); - await WaitForProvisioningAsync(rootClient, tenant2Id); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, tenant1Id); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, tenant2Id); // Register same email in tenant 1 using var client1 = await CreateTenantAdminClientWithRetryAsync( @@ -121,40 +121,4 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant var body = await response.Content.ReadAsStringAsync(); response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); } - - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (int i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"Tenant {tenantId} provisioning failed: {content}"); - } - } - - await Task.Delay(1000); - } - - // Get final status for the error message - var finalResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - var finalContent = finalResponse.IsSuccessStatusCode - ? await finalResponse.Content.ReadAsStringAsync() - : $"HTTP {finalResponse.StatusCode}"; - - throw new TimeoutException( - $"Tenant {tenantId} provisioning did not complete within {maxRetries} seconds. Last status: {finalContent}"); - } } diff --git a/src/Tests/Integration.Tests/Tests/Multitenancy/TenantMigrationsTests.cs b/src/Tests/Integration.Tests/Tests/Multitenancy/TenantMigrationsTests.cs index cac01e5cb2..5a693b1a1c 100644 --- a/src/Tests/Integration.Tests/Tests/Multitenancy/TenantMigrationsTests.cs +++ b/src/Tests/Integration.Tests/Tests/Multitenancy/TenantMigrationsTests.cs @@ -67,7 +67,7 @@ public async Task GetMigrations_Should_IncludeNewlyProvisionedTenant_When_Provis var unique = Guid.NewGuid().ToString("N")[..8]; var tenantId = $"mig-{unique}"; await CreateTenantAsync(rootClient, tenantId, $"mig-{unique}@tenant.com"); - await WaitForProvisioningAsync(rootClient, tenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, tenantId); // Act var response = await rootClient.GetAsync(MigrationsPath); @@ -112,7 +112,7 @@ public async Task GetMigrations_Should_Forbid_When_CallerIsTenantAdmin() var tenantId = $"mig-authz-{unique}"; var adminEmail = $"mig-authz-{unique}@tenant.com"; await CreateTenantAsync(rootClient, tenantId, adminEmail); - await WaitForProvisioningAsync(rootClient, tenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, tenantId); using var tenantClient = await CreateTenantAdminClientWithRetryAsync( adminEmail, TestConstants.DefaultPassword, tenantId); @@ -160,28 +160,6 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); } - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (var i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync($"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException($"Tenant {tenantId} provisioning failed: {content}"); - } - } - await Task.Delay(1000); - } - throw new TimeoutException($"Tenant {tenantId} did not finish provisioning."); - } - private sealed record MigrationStatus { public string TenantId { get; init; } = string.Empty; diff --git a/src/Tests/Integration.Tests/Tests/Multitenancy/TenantSeedDataTests.cs b/src/Tests/Integration.Tests/Tests/Multitenancy/TenantSeedDataTests.cs index 59c77f23a2..0fc3ff8697 100644 --- a/src/Tests/Integration.Tests/Tests/Multitenancy/TenantSeedDataTests.cs +++ b/src/Tests/Integration.Tests/Tests/Multitenancy/TenantSeedDataTests.cs @@ -21,7 +21,7 @@ public async Task NewTenant_Should_BeSeededWith_AdminUser_And_Permissions() var adminEmail = $"seed-admin-{uniqueId}@tenant.com"; await CreateTenantAsync(rootClient, tenantId, adminEmail); - await WaitForProvisioningAsync(rootClient, tenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, tenantId); using var tenantAdminClient = await CreateTenantAdminClientWithRetryAsync( adminEmail, TestConstants.DefaultPassword, tenantId); @@ -74,32 +74,4 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant var body = await response.Content.ReadAsStringAsync(); response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); } - - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (int i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"Tenant {tenantId} provisioning failed: {content}"); - } - } - - await Task.Delay(1000); - } - - throw new TimeoutException( - $"Tenant {tenantId} provisioning did not complete within {maxRetries} seconds."); - } } diff --git a/src/Tests/Integration.Tests/Tests/Multitenancy/TenantThemeTests.cs b/src/Tests/Integration.Tests/Tests/Multitenancy/TenantThemeTests.cs index 1f305f7b5e..792dc0a01d 100644 --- a/src/Tests/Integration.Tests/Tests/Multitenancy/TenantThemeTests.cs +++ b/src/Tests/Integration.Tests/Tests/Multitenancy/TenantThemeTests.cs @@ -51,8 +51,8 @@ public async Task InitializeAsync() using var rootClient = await _auth.CreateRootAdminClientAsync(); await CreateTenantAsync(rootClient, _tenantA, _tenantAAdminEmail); await CreateTenantAsync(rootClient, _tenantB, _tenantBAdminEmail); - await WaitForProvisioningAsync(rootClient, _tenantA); - await WaitForProvisioningAsync(rootClient, _tenantB); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, _tenantA); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, _tenantB); // Ensure both tenant admins are queryable (token issuance is the strongest // cross-check that identity seeding finished — see TenantHeaderOverrideTests). @@ -425,28 +425,6 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); } - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (var i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync($"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException($"Tenant {tenantId} provisioning failed: {content}"); - } - } - await Task.Delay(1000); - } - throw new TimeoutException($"Tenant {tenantId} did not finish provisioning."); - } - // Local copy of the theme response shape — only the fields these tests assert on. private sealed record TenantThemeDto { diff --git a/src/Tests/Integration.Tests/Tests/Roles/RolePrivilegeEscalationTests.cs b/src/Tests/Integration.Tests/Tests/Roles/RolePrivilegeEscalationTests.cs index 6095b5812f..6a13dea5c7 100644 --- a/src/Tests/Integration.Tests/Tests/Roles/RolePrivilegeEscalationTests.cs +++ b/src/Tests/Integration.Tests/Tests/Roles/RolePrivilegeEscalationTests.cs @@ -34,7 +34,7 @@ public async Task UpdateRolePermissions_Should_StripRootPermissions_When_CallerI var tenantId = $"esc-{unique}"; var adminEmail = $"esc-admin-{unique}@tenant.com"; await CreateTenantAsync(rootClient, tenantId, adminEmail); - await WaitForProvisioningAsync(rootClient, tenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, tenantId); using var tenantAdmin = await CreateTenantAdminClientWithRetryAsync( adminEmail, TestConstants.DefaultPassword, tenantId); @@ -123,28 +123,6 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant resp.StatusCode.ShouldBe(HttpStatusCode.Created, await resp.Content.ReadAsStringAsync()); } - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (var i = 0; i < maxRetries; i++) - { - var resp = await client.GetAsync($"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - if (resp.IsSuccessStatusCode) - { - var content = await resp.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException($"Tenant {tenantId} provisioning failed: {content}"); - } - } - await Task.Delay(1000); - } - throw new TimeoutException($"Tenant {tenantId} did not finish provisioning."); - } - private sealed record RoleRow { public string Id { get; init; } = string.Empty; diff --git a/src/Tests/Integration.Tests/Tests/Roles/RoleTenantIsolationTests.cs b/src/Tests/Integration.Tests/Tests/Roles/RoleTenantIsolationTests.cs index f52fd19501..d1997693b3 100644 --- a/src/Tests/Integration.Tests/Tests/Roles/RoleTenantIsolationTests.cs +++ b/src/Tests/Integration.Tests/Tests/Roles/RoleTenantIsolationTests.cs @@ -33,7 +33,7 @@ public async Task GetRoles_Should_NotReturnRoleCreatedInAnotherTenant() var rootRole = await CreateRoleAsync(rootClient, rootRoleName); await CreateTenantAsync(rootClient, otherTenantId, otherAdminEmail); - await WaitForProvisioningAsync(rootClient, otherTenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, otherTenantId); using var otherClient = await CreateTenantAdminClientWithRetryAsync( otherAdminEmail, TestConstants.DefaultPassword, otherTenantId); @@ -69,7 +69,7 @@ public async Task GetRoleById_Should_Return404_When_RoleBelongsToAnotherTenant() var rootRole = await CreateRoleAsync(rootClient, $"RootByIdRole-{uniqueId}"); await CreateTenantAsync(rootClient, otherTenantId, otherAdminEmail); - await WaitForProvisioningAsync(rootClient, otherTenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, otherTenantId); using var otherClient = await CreateTenantAdminClientWithRetryAsync( otherAdminEmail, TestConstants.DefaultPassword, otherTenantId); @@ -134,39 +134,4 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant var body = await response.Content.ReadAsStringAsync(); response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); } - - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (int i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"Tenant {tenantId} provisioning failed: {content}"); - } - } - - await Task.Delay(1000); - } - - var finalResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - var finalContent = finalResponse.IsSuccessStatusCode - ? await finalResponse.Content.ReadAsStringAsync() - : $"HTTP {finalResponse.StatusCode}"; - - throw new TimeoutException( - $"Tenant {tenantId} provisioning did not complete within {maxRetries} seconds. Last status: {finalContent}"); - } } diff --git a/src/Tests/Integration.Tests/Tests/Sessions/SessionCleanupTests.cs b/src/Tests/Integration.Tests/Tests/Sessions/SessionCleanupTests.cs index b891572518..f59f3d068f 100644 --- a/src/Tests/Integration.Tests/Tests/Sessions/SessionCleanupTests.cs +++ b/src/Tests/Integration.Tests/Tests/Sessions/SessionCleanupTests.cs @@ -43,7 +43,7 @@ public async Task Cleanup_Should_DeleteSessionsPastRetention_InEveryTenant_And_K var otherAdminEmail = $"sess-clean-admin-{uniqueId}@tenant.com"; await CreateTenantAsync(rootClient, otherTenantId, otherAdminEmail); - await WaitForProvisioningAsync(rootClient, otherTenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, otherTenantId); var now = DateTime.UtcNow; var rootExpired = await SeedSessionAsync( @@ -130,36 +130,4 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant var body = await response.Content.ReadAsStringAsync(); response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); } - - // The status body also lists each step, and a finished step reads "Completed" while later steps - // (seeding the tenant admin) are still running, so only the overall Status field is trusted. - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId) - { - const int maxRetries = 60; - for (int i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - - if (statusResponse.IsSuccessStatusCode) - { - var status = await statusResponse.Content.ReadFromJsonAsync(); - if (string.Equals(status?.Status, "Completed", StringComparison.Ordinal)) - { - return; - } - - if (string.Equals(status?.Status, "Failed", StringComparison.Ordinal)) - { - throw new InvalidOperationException( - $"Tenant {tenantId} provisioning failed at {status?.CurrentStep}: {status?.Error}"); - } - } - - await Task.Delay(1000); - } - - throw new TimeoutException( - $"Tenant {tenantId} provisioning did not complete within {maxRetries} seconds."); - } } diff --git a/src/Tests/Integration.Tests/Tests/Sessions/SessionTenantIsolationTests.cs b/src/Tests/Integration.Tests/Tests/Sessions/SessionTenantIsolationTests.cs index 53596337fc..5b3aca892e 100644 --- a/src/Tests/Integration.Tests/Tests/Sessions/SessionTenantIsolationTests.cs +++ b/src/Tests/Integration.Tests/Tests/Sessions/SessionTenantIsolationTests.cs @@ -32,7 +32,7 @@ public async Task GetTenantSessions_Should_NotExposeOtherTenantsSessions() var otherAdminEmail = $"sess-iso-admin-{uniqueId}@tenant.com"; await CreateTenantAsync(rootClient, otherTenantId, otherAdminEmail); - await WaitForProvisioningAsync(rootClient, otherTenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, otherTenantId); var rootUser = await IdentityUserSeeder.CreateLoginableUserAsync(_factory, rootClient, "sess-iso-rootuser"); await _auth.GetTokenAsync(rootUser.Email, rootUser.Password); @@ -65,7 +65,7 @@ public async Task GetUserSessions_Should_ReturnEmpty_When_UserBelongsToAnotherTe var otherAdminEmail = $"sess-iso2-admin-{uniqueId}@tenant.com"; await CreateTenantAsync(rootClient, otherTenantId, otherAdminEmail); - await WaitForProvisioningAsync(rootClient, otherTenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, otherTenantId); var rootUser = await IdentityUserSeeder.CreateLoginableUserAsync(_factory, rootClient, "sess-iso2-rootuser"); await _auth.GetTokenAsync(rootUser.Email, rootUser.Password); @@ -115,33 +115,4 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant var body = await response.Content.ReadAsStringAsync(); response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); } - - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (int i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"Tenant {tenantId} provisioning failed: {content}"); - } - } - - await Task.Delay(1000); - } - - throw new TimeoutException( - $"Tenant {tenantId} provisioning did not complete within {maxRetries} seconds."); - } } diff --git a/src/Tests/Integration.Tests/Tests/Tickets/TicketTenantIsolationTests.cs b/src/Tests/Integration.Tests/Tests/Tickets/TicketTenantIsolationTests.cs index f475a9ef71..be920b43b3 100644 --- a/src/Tests/Integration.Tests/Tests/Tickets/TicketTenantIsolationTests.cs +++ b/src/Tests/Integration.Tests/Tests/Tickets/TicketTenantIsolationTests.cs @@ -116,7 +116,7 @@ private async Task ProvisionTenantClientAsync(HttpClient rootClient, { var adminEmail = $"{tenantId}-admin@tenant.com"; await CreateTenantAsync(rootClient, tenantId, adminEmail); - await WaitForProvisioningAsync(rootClient, tenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, tenantId); return await CreateTenantAdminClientWithRetryAsync( adminEmail, TestConstants.DefaultPassword, tenantId); } @@ -153,39 +153,4 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant var body = await response.Content.ReadAsStringAsync(); response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); } - - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (int i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"Tenant {tenantId} provisioning failed: {content}"); - } - } - - await Task.Delay(1000); - } - - var finalResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - var finalContent = finalResponse.IsSuccessStatusCode - ? await finalResponse.Content.ReadAsStringAsync() - : $"HTTP {finalResponse.StatusCode}"; - - throw new TimeoutException( - $"Tenant {tenantId} provisioning did not complete within {maxRetries} seconds. Last status: {finalContent}"); - } } diff --git a/src/Tests/Integration.Tests/Tests/Webhooks/WebhookDeliveryTests.cs b/src/Tests/Integration.Tests/Tests/Webhooks/WebhookDeliveryTests.cs index 90abbcb60b..c36246c242 100644 --- a/src/Tests/Integration.Tests/Tests/Webhooks/WebhookDeliveryTests.cs +++ b/src/Tests/Integration.Tests/Tests/Webhooks/WebhookDeliveryTests.cs @@ -220,7 +220,7 @@ public async Task GetDeliveries_Should_NotLeakOtherTenantsDeliveries_When_Querie var otherTenantId = $"wh-deliv-iso-{uniqueId}"; var otherAdminEmail = $"wh-deliv-iso-{uniqueId}@tenant.com"; await CreateTenantAsync(rootClient, otherTenantId, otherAdminEmail); - await WaitForProvisioningAsync(rootClient, otherTenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, otherTenantId); using var otherClient = await CreateTenantAdminClientWithRetryAsync( capturingFactory, otherAdminEmail, TestConstants.DefaultPassword, otherTenantId); @@ -372,33 +372,6 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); } - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (int i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException($"Tenant {tenantId} provisioning failed: {content}"); - } - } - - await Task.Delay(1000); - } - - throw new TimeoutException($"Tenant {tenantId} provisioning did not complete within {maxRetries} seconds."); - } - /// /// Test transport for the named "Webhooks" HttpClient. Returns a steerable status code and records /// the outbound request's content headers (signature/event/delivery-id are set on the CONTENT). diff --git a/src/Tests/Integration.Tests/Tests/Webhooks/WebhookTenantIsolationTests.cs b/src/Tests/Integration.Tests/Tests/Webhooks/WebhookTenantIsolationTests.cs index 485775c50c..a133298fcc 100644 --- a/src/Tests/Integration.Tests/Tests/Webhooks/WebhookTenantIsolationTests.cs +++ b/src/Tests/Integration.Tests/Tests/Webhooks/WebhookTenantIsolationTests.cs @@ -22,7 +22,7 @@ public async Task GetSubscriptions_Should_OnlyReturnCurrentTenantsSubscriptions( var otherAdminEmail = $"webhook-admin-{uniqueId}@tenant.com"; await CreateTenantAsync(rootClient, otherTenantId, otherAdminEmail); - await WaitForProvisioningAsync(rootClient, otherTenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, otherTenantId); using var otherClient = await CreateTenantAdminClientWithRetryAsync( otherAdminEmail, TestConstants.DefaultPassword, otherTenantId); @@ -63,7 +63,7 @@ public async Task DeleteSubscription_Should_Return404_When_OwnedByDifferentTenan var otherAdminEmail = $"webhook-deladmin-{uniqueId}@tenant.com"; await CreateTenantAsync(rootClient, otherTenantId, otherAdminEmail); - await WaitForProvisioningAsync(rootClient, otherTenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, otherTenantId); using var otherClient = await CreateTenantAdminClientWithRetryAsync( otherAdminEmail, TestConstants.DefaultPassword, otherTenantId); @@ -95,7 +95,7 @@ public async Task TestSubscription_Should_Return404_When_OwnedByDifferentTenant( var otherAdminEmail = $"webhook-testadmin-{uniqueId}@tenant.com"; await CreateTenantAsync(rootClient, otherTenantId, otherAdminEmail); - await WaitForProvisioningAsync(rootClient, otherTenantId); + await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, otherTenantId); using var otherClient = await CreateTenantAdminClientWithRetryAsync( otherAdminEmail, TestConstants.DefaultPassword, otherTenantId); @@ -147,39 +147,4 @@ private static async Task CreateTenantAsync(HttpClient rootClient, string tenant var body = await response.Content.ReadAsStringAsync(); response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}"); } - - private static async Task WaitForProvisioningAsync(HttpClient client, string tenantId, int maxRetries = 60) - { - for (int i = 0; i < maxRetries; i++) - { - var statusResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - - if (statusResponse.IsSuccessStatusCode) - { - var content = await statusResponse.Content.ReadAsStringAsync(); - if (content.Contains("Completed", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - if (content.Contains("Failed", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"Tenant {tenantId} provisioning failed: {content}"); - } - } - - await Task.Delay(1000); - } - - var finalResponse = await client.GetAsync( - $"{TestConstants.TenantsBasePath}/{tenantId}/provisioning"); - var finalContent = finalResponse.IsSuccessStatusCode - ? await finalResponse.Content.ReadAsStringAsync() - : $"HTTP {finalResponse.StatusCode}"; - - throw new TimeoutException( - $"Tenant {tenantId} provisioning did not complete within {maxRetries} seconds. Last status: {finalContent}"); - } }