Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/Host/FSH.Starter.Api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
# <ContainerFamily>noble</ContainerFamily> (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 .

Expand Down
12 changes: 7 additions & 5 deletions src/Host/FSH.Starter.Api/appsettings.Production.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,13 @@
},
"MailOptions": {
"From": "",
"Host": "",
"Port": 0,
"UserName": "",
"Password": "",
"DisplayName": ""
"DisplayName": "",
"Smtp": {
"Host": "",
"Port": 587,
"UserName": "",
"Password": ""
}
},
"RateLimitingOptions": {
"Enabled": true,
Expand Down
5 changes: 4 additions & 1 deletion src/Host/FSH.Starter.DbMigrator/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 .

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using FSH.Modules.Multitenancy.Contracts.Dtos;

namespace Integration.Tests.Infrastructure;

/// <summary>
/// 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 <c>Status</c> field is trusted.
/// </summary>
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<TenantProvisioningStatusDto>();
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 ?? "<none>"} (step {status?.CurrentStep ?? "<none>"}).");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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}");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -427,7 +427,7 @@ private async Task<HttpClient> 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);
}
Expand Down Expand Up @@ -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
}
26 changes: 2 additions & 24 deletions src/Tests/Integration.Tests/Tests/Billing/InvoicePdfTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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.");
}
}
24 changes: 1 addition & 23 deletions src/Tests/Integration.Tests/Tests/Billing/TopupApprovalTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<HttpClient> CreateTenantAdminClientWithRetryAsync(
string email, string password, string tenant, int maxRetries = 30)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<HttpClient> CreateTenantAdminClientWithRetryAsync(
string email, string password, string tenant, int maxRetries = 30)
{
Expand Down
Loading
Loading