From 216757cfd12b0dbb3832f05c4718bfd8b1193e2d Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Tue, 29 Sep 2026 16:54:13 +0530 Subject: [PATCH 1/4] chore(docker): move API and migrator runtime images off dotnet/nightly Both production Dockerfiles pulled mcr.microsoft.com/dotnet/nightly/aspnet, left over from when .NET 10 was in preview. Nightly images are unsupported and can change under you. Use the supported aspnet:10.0-noble-chiseled. Also document the deliberate drift: the Dockerfiles (docker compose) stay chiseled, while the csproj ContainerFamily (SDK publish, AWS deploy) is full noble because chiseled strips libgssapi_krb5 and Npgsql's GSSAPI probe then logs a noisy load error. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Host/FSH.Starter.Api/Dockerfile | 6 +++++- src/Host/FSH.Starter.DbMigrator/Dockerfile | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) 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.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 . From 10aaab4671e6e889e8f2d598d4524ad05fad56bf Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Tue, 29 Sep 2026 16:54:14 +0530 Subject: [PATCH 2/4] fix(config): nest Production SMTP keys under MailOptions:Smtp Host/Port/UserName/Password sat directly under MailOptions, where nothing binds them (SmtpOptions lives at MailOptions:Smtp). Move them into the Smtp section so Production overrides actually apply. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Host/FSH.Starter.Api/appsettings.Production.json | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Host/FSH.Starter.Api/appsettings.Production.json b/src/Host/FSH.Starter.Api/appsettings.Production.json index 1f26741901..684b039330 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": 0, + "UserName": "", + "Password": "" + } }, "RateLimitingOptions": { "Enabled": true, From 76f9d7b7f3ae82d409166e93b86991bc83d6ecff Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Tue, 29 Sep 2026 16:58:30 +0530 Subject: [PATCH 3/4] test(integration): share one provisioning wait that checks the overall Status 27 test classes carried their own copy of WaitForProvisioningAsync, and 26 treated "Completed" anywhere in the status body as done. The body lists each step, so that matched as soon as the first step finished, before the tenant admin was seeded - a likely flake source. Extract the correct wait from #1406 (reads TenantProvisioningStatusDto and trusts only its Status field, throws on Failed with the step and error) into Infrastructure/TenantProvisioningWait and use it everywhere. The timeout message now reports the last status and step. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Infrastructure/TenantProvisioningWait.cs | 46 +++++++++++++++++++ .../AuditQueryTenantIsolationTests.cs | 31 +------------ .../Auditing/AuditTenantIsolationTests.cs | 37 +-------------- .../Billing/BillingTenantIsolationTests.cs | 41 ++--------------- .../Tests/Billing/InvoicePdfTests.cs | 26 +---------- .../Tests/Billing/TopupApprovalTests.cs | 24 +--------- .../Tests/Billing/WalletEndpointsTests.cs | 37 +-------------- .../Catalog/CatalogTenantIsolationTests.cs | 37 +-------------- .../Tests/Chat/ChatTenantIsolationTests.cs | 37 +-------------- .../Tests/Files/FileTenantIsolationTests.cs | 37 +-------------- .../Tests/Groups/GroupTenantIsolationTests.cs | 37 +-------------- .../Tests/Impersonation/ImpersonationTests.cs | 26 +---------- .../Multitenancy/AdjustTenantValidityTests.cs | 24 +--------- .../ChangeTenantActivationValidationTests.cs | 24 +--------- .../Tests/Multitenancy/MyTenantStatusTests.cs | 24 +--------- .../Tests/Multitenancy/RenewTenantTests.cs | 26 +---------- .../Multitenancy/TenantHeaderOverrideTests.cs | 26 +---------- .../Multitenancy/TenantIsolationTests.cs | 42 ++--------------- .../Multitenancy/TenantMigrationsTests.cs | 26 +---------- .../Tests/Multitenancy/TenantSeedDataTests.cs | 30 +----------- .../Tests/Multitenancy/TenantThemeTests.cs | 26 +---------- .../Roles/RolePrivilegeEscalationTests.cs | 24 +--------- .../Tests/Roles/RoleTenantIsolationTests.cs | 39 +--------------- .../Tests/Sessions/SessionCleanupTests.cs | 34 +------------- .../Sessions/SessionTenantIsolationTests.cs | 33 +------------ .../Tickets/TicketTenantIsolationTests.cs | 37 +-------------- .../Tests/Webhooks/WebhookDeliveryTests.cs | 29 +----------- .../Webhooks/WebhookTenantIsolationTests.cs | 41 ++--------------- 28 files changed, 87 insertions(+), 814 deletions(-) create mode 100644 src/Tests/Integration.Tests/Infrastructure/TenantProvisioningWait.cs 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}"); - } } From 57125e2ce5b840dcf6cbff469b20809f746f9796 Mon Sep 17 00:00:00 2001 From: iammukeshm Date: Wed, 30 Sep 2026 08:25:47 +0530 Subject: [PATCH 4/4] fix(config): default the Production SMTP port to 587, not 0 The keys now bind, so a Port of 0 would override the base 587 and break an operator who only sets the host. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/Host/FSH.Starter.Api/appsettings.Production.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Host/FSH.Starter.Api/appsettings.Production.json b/src/Host/FSH.Starter.Api/appsettings.Production.json index 684b039330..6c40734bbd 100644 --- a/src/Host/FSH.Starter.Api/appsettings.Production.json +++ b/src/Host/FSH.Starter.Api/appsettings.Production.json @@ -85,7 +85,7 @@ "DisplayName": "", "Smtp": { "Host": "", - "Port": 0, + "Port": 587, "UserName": "", "Password": "" }