+ Couldn't load invoices. Try refreshing.
+
+ );
+ }
+
if (invoices.length === 0) {
return (
diff --git a/clients/dashboard/tests/billing/subscription.spec.ts b/clients/dashboard/tests/billing/subscription.spec.ts
index 24830c1713..1e272d07e4 100644
--- a/clients/dashboard/tests/billing/subscription.spec.ts
+++ b/clients/dashboard/tests/billing/subscription.spec.ts
@@ -48,6 +48,14 @@ const GRACE_STATUS = {
graceEndsUtc: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
};
+/** Fully lapsed — grace exhausted. Persistent danger bar should appear. */
+const EXPIRED_STATUS = {
+ ...HEALTHY_STATUS,
+ validUpto: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
+ expiryState: "Expired",
+ graceEndsUtc: new Date(Date.now() - 16 * 24 * 60 * 60 * 1000).toISOString(),
+};
+
const USAGE = [
{
id: "use-1",
@@ -184,6 +192,16 @@ test.describe("expiry banner", () => {
await expect(page.getByText(/your subscription expires in/i)).toBeVisible();
});
+ test("pins a persistent bar when expired (no dismiss)", async ({ page }) => {
+ await mockJsonResponse(page, "**/api/v1/tenants/me/status**", EXPIRED_STATUS);
+ await page.goto("/");
+ await expect(page.getByText(/your subscription has expired/i)).toBeVisible();
+ // Expired is the hardest state — it cannot be dismissed.
+ await expect(
+ page.getByRole("button", { name: /dismiss subscription notice/i }),
+ ).toHaveCount(0);
+ });
+
test("is absent when healthy", async ({ page }) => {
await mockJsonResponse(page, "**/api/v1/tenants/me/status**", HEALTHY_STATUS);
await page.goto("/");
diff --git a/clients/dashboard/tests/chat/chat.spec.ts b/clients/dashboard/tests/chat/chat.spec.ts
index a08bba1b06..b1c3c6e412 100644
--- a/clients/dashboard/tests/chat/chat.spec.ts
+++ b/clients/dashboard/tests/chat/chat.spec.ts
@@ -29,12 +29,12 @@ import { installShellMocks } from "../helpers/shell-mocks";
const CHANNEL_ID = "00000000-0000-0000-0000-0000000c1111";
-// type 2 = Channel (named, public). Using a named channel keeps the header /
+// type "Channel" (named, public). Using a named channel keeps the header /
// composer title deterministic — channelTitle returns channel.name verbatim
// (no Identity lookup needed, unlike DMs which resolve a partner name).
const CHANNEL_ENGINEERING = {
id: CHANNEL_ID,
- type: 2,
+ type: "Channel",
name: "engineering",
slug: "engineering",
description: "Where the builders talk.",
@@ -48,7 +48,7 @@ const CHANNEL_ENGINEERING = {
{
id: "m-1",
userId: TEST_USER.sub,
- role: 1,
+ role: "Admin",
joinedAtUtc: "2026-05-01T10:00:00Z",
lastReadMessageId: null,
isMuted: false,
diff --git a/clients/dashboard/tests/files/files.spec.ts b/clients/dashboard/tests/files/files.spec.ts
index e77bb0a53d..03c6c9e21d 100644
--- a/clients/dashboard/tests/files/files.spec.ts
+++ b/clients/dashboard/tests/files/files.spec.ts
@@ -24,8 +24,8 @@ const FILE_REPORT = {
originalFileName: "quarterly-report.pdf",
contentType: "application/pdf",
sizeBytes: 248_000,
- visibility: 1, // Private
- status: 1, // Available
+ visibility: "Private",
+ status: "Available",
scanStatus: 1,
createdAtUtc: "2026-05-10T10:00:00Z",
publicUrl: null,
@@ -39,8 +39,8 @@ const FILE_SHARED = {
originalFileName: "team-handbook.pdf",
contentType: "application/pdf",
sizeBytes: 512_000,
- visibility: 0, // Public
- status: 1,
+ visibility: "Public",
+ status: "Available",
scanStatus: 1,
createdAtUtc: "2026-05-11T10:00:00Z",
publicUrl: "https://cdn.example.com/team-handbook.pdf",
diff --git a/clients/dashboard/tests/overview/overview.spec.ts b/clients/dashboard/tests/overview/overview.spec.ts
index ca7e6c4c0c..7c82200e0a 100644
--- a/clients/dashboard/tests/overview/overview.spec.ts
+++ b/clients/dashboard/tests/overview/overview.spec.ts
@@ -56,6 +56,44 @@ test.describe("overview (/)", () => {
// Empty usage → calm empty state, not a crash.
await expect(page.getByText(/no usage captured yet/i)).toBeVisible();
});
+
+ test("Valid-for card reflects an in-grace tenant", async ({ page }) => {
+ await mockJsonResponse(page, "**/api/v1/tenants/me/status**", {
+ id: "acme",
+ name: "Acme Corp",
+ isActive: true,
+ validUpto: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
+ hasConnectionString: false,
+ adminEmail: "admin@acme.com",
+ issuer: null,
+ plan: "Scale",
+ expiryState: "InGrace",
+ graceEndsUtc: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
+ });
+ await page.goto("/");
+ await expect(page.getByText("Valid for", { exact: true })).toBeVisible();
+ // Grace surfaces the grace-end caption on the stat card.
+ await expect(page.getByText(/grace ends/i)).toBeVisible();
+ });
+
+ test("Valid-for card reflects an expired tenant", async ({ page }) => {
+ await mockJsonResponse(page, "**/api/v1/tenants/me/status**", {
+ id: "acme",
+ name: "Acme Corp",
+ isActive: false,
+ validUpto: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
+ hasConnectionString: false,
+ adminEmail: "admin@acme.com",
+ issuer: null,
+ plan: "Scale",
+ expiryState: "Expired",
+ graceEndsUtc: new Date(Date.now() - 16 * 24 * 60 * 60 * 1000).toISOString(),
+ });
+ await page.goto("/");
+ await expect(page.getByText("Valid for", { exact: true })).toBeVisible();
+ // The stat card reads "Expired" rather than a healthy day count.
+ await expect(page.getByText("Expired", { exact: true })).toBeVisible();
+ });
});
test.describe("activity (/activity)", () => {
@@ -100,4 +138,35 @@ test.describe("invoices (/invoices)", () => {
await page.getByPlaceholder(/search by invoice number/i).fill("nomatch-xyz");
await expect(page.getByText(/no invoices found/i)).toBeVisible();
});
+
+ test("paginates across pages using the PagedResult envelope", async ({ page }) => {
+ const PAGE_1 = { ...INVOICE, id: "inv-1", invoiceNumber: "INV-2026-05" };
+ const PAGE_2 = { ...INVOICE, id: "inv-2", invoiceNumber: "INV-2026-04", periodMonth: 4 };
+
+ // Serve page 1 or page 2 based on the requested pageNumber so the next
+ // control drives a real envelope transition. totalCount=2, totalPages=2.
+ await page.route("**/api/v1/billing/invoices/me**", async (route) => {
+ const url = new URL(route.request().url());
+ const pageNumber = Number(url.searchParams.get("pageNumber") ?? "1");
+ const item = pageNumber >= 2 ? PAGE_2 : PAGE_1;
+ await route.fulfill({
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(
+ paged([item], { pageNumber, pageSize: 20, totalCount: 2, totalPages: 2 }),
+ ),
+ });
+ });
+
+ await page.goto("/invoices");
+ // Header reflects the TRUE total, not the loaded page size.
+ await expect(page.getByText(/showing 1 of 2 invoices/i)).toBeVisible();
+ await expect(page.getByText("INV-2026-05").last()).toBeVisible();
+ await expect(page.getByText("Page 1 of 2", { exact: true })).toBeVisible();
+
+ await page.getByRole("button", { name: /next page/i }).click();
+
+ await expect(page.getByText("INV-2026-04").last()).toBeVisible();
+ await expect(page.getByText("Page 2 of 2", { exact: true })).toBeVisible();
+ });
});
diff --git a/clients/dashboard/tests/system/audits.spec.ts b/clients/dashboard/tests/system/audits.spec.ts
index 223f0b2c92..b652ae6daa 100644
--- a/clients/dashboard/tests/system/audits.spec.ts
+++ b/clients/dashboard/tests/system/audits.spec.ts
@@ -3,13 +3,14 @@ import { mockJsonResponse } from "../helpers/api-mocks";
import { seedAuthedSession, TEST_USER } from "../helpers/auth-seed";
import { installShellMocks, paged } from "../helpers/shell-mocks";
-// AuditEventType: 1 Entity, 2 Security, 3 Activity, 4 Exception
-// AuditSeverity: 3 Info, 4 Warn, 5 Error, 6 Critical
+// The API serializes enums as their string name (global JsonStringEnumConverter).
+// AuditEventType: EntityChange | Security | Activity | Exception
+// AuditSeverity: Information | Warning | Error | Critical
type AuditRow = {
id: string;
occurredAtUtc: string;
- eventType: number;
- severity: number;
+ eventType: string;
+ severity: string;
tenantId?: string | null;
userId?: string | null;
userName?: string | null;
@@ -24,8 +25,8 @@ function row(over: Partial
= {}): AuditRow {
return {
id: "a-1",
occurredAtUtc: "2026-05-20T14:32:11.234Z",
- eventType: 2,
- severity: 5,
+ eventType: "Security",
+ severity: "Error",
tenantId: "acme",
userId: "11111111-2222-3333-4444-555555555555",
userName: "Alice Nguyen",
@@ -40,8 +41,8 @@ async function mockSummary(page: import("@playwright/test").Page) {
// register the summary mock AFTER the list mock — most-recently-registered
// route wins in Playwright, so the summary call resolves to this shape.
await mockJsonResponse(page, "**/api/v1/audits/summary**", {
- eventsByType: { "2": 3, "3": 10 },
- eventsBySeverity: { "3": 8, "5": 5 },
+ eventsByType: { Security: 3, Activity: 10 },
+ eventsBySeverity: { Information: 8, Error: 5 },
eventsBySource: { "api.identity.RegisterUser": 4 },
eventsByTenant: { acme: 13 },
});
@@ -102,11 +103,11 @@ test.describe("system/audits", () => {
page.getByRole("heading", { name: "Audit trail", level: 1 }),
).toBeVisible();
- // Security == AuditEventType 2 → EventType=2 in the query string.
+ // Security → EventType=Security in the query string.
const reqPromise = page.waitForRequest(
(r) =>
r.url().includes("/api/v1/audits?") &&
- /[?&]EventType=2(&|$)/.test(r.url()),
+ /[?&]EventType=Security(&|$)/.test(r.url()),
{ timeout: 5_000 },
);
await page.getByRole("button", { name: "Security", exact: true }).click();
diff --git a/src/Host/FSH.Starter.Api/Program.cs b/src/Host/FSH.Starter.Api/Program.cs
index 2a60bab47e..411421ebd4 100644
--- a/src/Host/FSH.Starter.Api/Program.cs
+++ b/src/Host/FSH.Starter.Api/Program.cs
@@ -12,9 +12,21 @@
using FSH.Modules.Tickets;
using FSH.Modules.Multitenancy.Features.v1.GetTenantStatus;
using System.Reflection;
+using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
+// Serialize all enums as their string names on the HTTP API (e.g. "Active" not 0).
+// Reading still accepts both names and integers (allowIntegerValues default = true),
+// so request payloads sending either form keep working. Single-value enums become
+// strings; [Flags] enums (AuditTag, BodyCapture) opt back to numeric via their own
+// [JsonConverter(NumericEnumConverter<>)] attribute, since a comma-joined flag string
+// breaks bitwise consumers. Frontends mirror this contract (string unions).
+builder.Services.ConfigureHttpJsonOptions(options =>
+{
+ options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
+});
+
if (builder.Environment.IsProduction())
{
static void Require(IConfiguration config, string key)
diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/AuditEnums.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/AuditEnums.cs
index a1f3e091a8..4df9006286 100644
--- a/src/Modules/Auditing/Modules.Auditing.Contracts/AuditEnums.cs
+++ b/src/Modules/Auditing/Modules.Auditing.Contracts/AuditEnums.cs
@@ -1,4 +1,6 @@
-namespace FSH.Modules.Auditing.Contracts;
+using System.Text.Json.Serialization;
+
+namespace FSH.Modules.Auditing.Contracts;
///
/// High-level classification of audit events.
@@ -88,6 +90,7 @@ public enum ExceptionArea
/// Indicates which HTTP bodies are captured in activity events.
///
[Flags]
+[JsonConverter(typeof(NumericEnumConverter))]
public enum BodyCapture
{
None = 0,
@@ -100,6 +103,7 @@ public enum BodyCapture
/// Compact, bitwise tags that provide additional audit metadata.
///
[Flags]
+[JsonConverter(typeof(NumericEnumConverter))]
public enum AuditTag
{
None = 0,
diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/NumericEnumConverter.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/NumericEnumConverter.cs
new file mode 100644
index 0000000000..a19c32cda9
--- /dev/null
+++ b/src/Modules/Auditing/Modules.Auditing.Contracts/NumericEnumConverter.cs
@@ -0,0 +1,41 @@
+using System.Globalization;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace FSH.Modules.Auditing.Contracts;
+
+///
+/// Forces an enum to serialize as its underlying integer even when a global
+/// is registered. Applied to [Flags]
+/// enums (, ) — a bitset is not a
+/// single named value, and the converter's comma-joined string form (e.g.
+/// "PiiMasked, Sampled") breaks bitwise consumers. Reading accepts an integer or,
+/// defensively, a comma/space-delimited list of member names.
+///
+public sealed class NumericEnumConverter : JsonConverter
+ where TEnum : struct, Enum
+{
+ public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType == JsonTokenType.Number)
+ {
+ return (TEnum)Enum.ToObject(typeof(TEnum), reader.GetInt64());
+ }
+
+ if (reader.TokenType == JsonTokenType.String)
+ {
+ var raw = reader.GetString();
+ return string.IsNullOrWhiteSpace(raw)
+ ? default
+ : Enum.Parse(raw, ignoreCase: true);
+ }
+
+ throw new JsonException($"Unexpected token {reader.TokenType} when reading {typeof(TEnum).Name}.");
+ }
+
+ public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(writer);
+ writer.WriteNumberValue(Convert.ToInt64(value, CultureInfo.InvariantCulture));
+ }
+}
diff --git a/src/Modules/Billing/Modules.Billing/Domain/Invoice.cs b/src/Modules/Billing/Modules.Billing/Domain/Invoice.cs
index a41171dbed..92a1397b43 100644
--- a/src/Modules/Billing/Modules.Billing/Domain/Invoice.cs
+++ b/src/Modules/Billing/Modules.Billing/Domain/Invoice.cs
@@ -129,6 +129,11 @@ public void Void(string? reason = null)
{
throw new InvalidOperationException("Paid invoices cannot be voided.");
}
+ if (Status is InvoiceStatus.Void)
+ {
+ // Idempotent: re-voiding must not re-stamp VoidedAtUtc or append the reason again.
+ return;
+ }
Status = InvoiceStatus.Void;
VoidedAtUtc = DateTime.UtcNow;
if (!string.IsNullOrWhiteSpace(reason))
diff --git a/src/Modules/Billing/Modules.Billing/Domain/Subscription.cs b/src/Modules/Billing/Modules.Billing/Domain/Subscription.cs
index 81dc875d68..5e5aaba7d7 100644
--- a/src/Modules/Billing/Modules.Billing/Domain/Subscription.cs
+++ b/src/Modules/Billing/Modules.Billing/Domain/Subscription.cs
@@ -61,4 +61,19 @@ public void Cancel(DateTime endUtc)
EndUtc = DateTime.SpecifyKind(endUtc, DateTimeKind.Utc);
UpdatedAtUtc = DateTime.UtcNow;
}
+
+ ///
+ /// Extends the active term's end. Used by a same-plan renewal to keep in step
+ /// with the tenant's ValidUpto (a plan change replaces the subscription instead). Idempotent: only
+ /// ever moves the end forward, so a redelivered renewal event is a no-op.
+ ///
+ public void Extend(DateTime endUtc)
+ {
+ var newEnd = DateTime.SpecifyKind(endUtc, DateTimeKind.Utc);
+ if (EndUtc is null || newEnd > EndUtc)
+ {
+ EndUtc = newEnd;
+ UpdatedAtUtc = DateTime.UtcNow;
+ }
+ }
}
diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GenerateInvoices/GenerateInvoicesCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GenerateInvoices/GenerateInvoicesCommandHandler.cs
index 8162434d33..c99a1b0c2a 100644
--- a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GenerateInvoices/GenerateInvoicesCommandHandler.cs
+++ b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GenerateInvoices/GenerateInvoicesCommandHandler.cs
@@ -1,15 +1,30 @@
+using Finbuckle.MultiTenant.Abstractions;
+using FSH.Framework.Core.Exceptions;
+using FSH.Framework.Shared.Multitenancy;
using FSH.Modules.Billing.Contracts.v1.Invoices;
using FSH.Modules.Billing.Services;
using Mediator;
namespace FSH.Modules.Billing.Features.v1.Invoices.GenerateInvoices;
-public sealed class GenerateInvoicesCommandHandler(IBillingService billing)
+public sealed class GenerateInvoicesCommandHandler(
+ IBillingService billing,
+ IMultiTenantContextAccessor tenantAccessor)
: ICommandHandler
{
public async ValueTask Handle(GenerateInvoicesCommand command, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(command);
+
+ // Platform-wide invoice generation runs across EVERY tenant — it is a root-operator action.
+ // A tenant admin (who also holds Billing.Manage) must not be able to trigger it.
+ var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id
+ ?? throw new UnauthorizedException("Tenant context is required.");
+ if (callerTenantId != MultitenancyConstants.Root.Id)
+ {
+ throw new ForbiddenException("Only the root operator may generate invoices across tenants.");
+ }
+
return await billing.GenerateInvoicesForAllTenantsAsync(command.PeriodYear, command.PeriodMonth, cancellationToken).ConfigureAwait(false);
}
}
diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoiceById/GetInvoiceByIdQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoiceById/GetInvoiceByIdQueryHandler.cs
index 949430c29a..0fcbced2ef 100644
--- a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoiceById/GetInvoiceByIdQueryHandler.cs
+++ b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoiceById/GetInvoiceByIdQueryHandler.cs
@@ -19,14 +19,18 @@ public async ValueTask Handle(GetInvoiceByIdQuery query, Cancellatio
ArgumentNullException.ThrowIfNull(query);
// BillingDbContext is not tenant-filtered (it extends raw DbContext for cross-tenant admin
- // visibility), so fetch-by-id MUST scope to the caller's tenant explicitly — otherwise any
- // tenant could read another tenant's invoice by guessing its id.
- var tenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id
+ // visibility). The root operator may read ANY invoice by id (the admin app lists invoices
+ // across every tenant and drills into them); a tenant caller is pinned to its own tenant so
+ // it can't read another tenant's invoice by guessing the id. Mirrors GetSubscriptionQueryHandler.
+ var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id
?? throw new UnauthorizedException("Tenant context is required.");
+ var isRoot = callerTenantId == MultitenancyConstants.Root.Id;
var invoice = await dbContext.Invoices.AsNoTracking()
.Include(i => i.LineItems)
- .FirstOrDefaultAsync(i => i.Id == query.InvoiceId && i.TenantId == tenantId, cancellationToken)
+ .FirstOrDefaultAsync(
+ i => i.Id == query.InvoiceId && (isRoot || i.TenantId == callerTenantId),
+ cancellationToken)
.ConfigureAwait(false)
?? throw new NotFoundException($"Invoice {query.InvoiceId} not found.");
diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoicePdf/GetInvoicePdfQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoicePdf/GetInvoicePdfQueryHandler.cs
index 13ba8ad3af..984d851b07 100644
--- a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoicePdf/GetInvoicePdfQueryHandler.cs
+++ b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoicePdf/GetInvoicePdfQueryHandler.cs
@@ -18,14 +18,18 @@ public async ValueTask Handle(GetInvoicePdfQuery query, Cancel
{
ArgumentNullException.ThrowIfNull(query);
- // BillingDbContext is not tenant-filtered, so scope the fetch to the caller's tenant explicitly
- // (mirrors GetInvoiceById) — cross-tenant ids resolve to 404, never leak another tenant's PDF.
- var tenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id
+ // BillingDbContext is not tenant-filtered (mirrors GetInvoiceById): the root operator may
+ // download ANY tenant's invoice PDF (admin drills into invoices across every tenant); a tenant
+ // caller is pinned to its own tenant so a cross-tenant id resolves to 404, never leaking a PDF.
+ var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id
?? throw new UnauthorizedException("Tenant context is required.");
+ var isRoot = callerTenantId == MultitenancyConstants.Root.Id;
var invoice = await dbContext.Invoices.AsNoTracking()
.Include(i => i.LineItems)
- .FirstOrDefaultAsync(i => i.Id == query.InvoiceId && i.TenantId == tenantId, cancellationToken)
+ .FirstOrDefaultAsync(
+ i => i.Id == query.InvoiceId && (isRoot || i.TenantId == callerTenantId),
+ cancellationToken)
.ConfigureAwait(false)
?? throw new NotFoundException($"Invoice {query.InvoiceId} not found.");
diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesEndpoint.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesEndpoint.cs
index 32347df8d5..09be6e9bd3 100644
--- a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesEndpoint.cs
+++ b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesEndpoint.cs
@@ -22,7 +22,7 @@ internal static RouteHandlerBuilder MapGetInvoicesEndpoint(this IEndpointRouteBu
periodYear,
periodMonth,
pageNumber <= 0 ? 1 : pageNumber,
- pageSize <= 0 ? 20 : pageSize), ct))
+ pageSize <= 0 ? 20 : Math.Min(pageSize, 100)), ct))
.WithName("GetInvoices")
.WithSummary("List invoices across all tenants (admin)")
.RequirePermission(BillingPermissions.View);
diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesQueryHandler.cs
index 904846dd80..ea23df7908 100644
--- a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesQueryHandler.cs
+++ b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetInvoices/GetInvoicesQueryHandler.cs
@@ -1,3 +1,6 @@
+using Finbuckle.MultiTenant.Abstractions;
+using FSH.Framework.Core.Exceptions;
+using FSH.Framework.Shared.Multitenancy;
using FSH.Framework.Shared.Persistence;
using FSH.Modules.Billing.Contracts.Dtos;
using FSH.Modules.Billing.Contracts.v1.Invoices;
@@ -7,17 +10,27 @@
namespace FSH.Modules.Billing.Features.v1.Invoices.GetInvoices;
-public sealed class GetInvoicesQueryHandler(BillingDbContext dbContext)
+public sealed class GetInvoicesQueryHandler(
+ BillingDbContext dbContext,
+ IMultiTenantContextAccessor tenantAccessor)
: IQueryHandler>
{
public async ValueTask> Handle(GetInvoicesQuery query, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(query);
+ // BillingDbContext is not tenant-filtered. Only the root operator gets the cross-tenant view
+ // (optionally narrowed to one tenant via query.TenantId); any other caller — even with the
+ // IsBasic Billing.View permission — is forced to its own tenant, so it can't list across tenants.
+ var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id
+ ?? throw new UnauthorizedException("Tenant context is required.");
+ var isRoot = callerTenantId == MultitenancyConstants.Root.Id;
+ var tenantFilter = isRoot ? query.TenantId : callerTenantId;
+
var q = dbContext.Invoices.AsNoTracking().Include(i => i.LineItems).AsQueryable();
- if (!string.IsNullOrWhiteSpace(query.TenantId))
+ if (!string.IsNullOrWhiteSpace(tenantFilter))
{
- q = q.Where(i => i.TenantId == query.TenantId);
+ q = q.Where(i => i.TenantId == tenantFilter);
}
if (query.Status is not null)
{
diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetMyInvoices/GetMyInvoicesEndpoint.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetMyInvoices/GetMyInvoicesEndpoint.cs
index dbfe2722a6..9c9c741df2 100644
--- a/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetMyInvoices/GetMyInvoicesEndpoint.cs
+++ b/src/Modules/Billing/Modules.Billing/Features/v1/Invoices/GetMyInvoices/GetMyInvoicesEndpoint.cs
@@ -19,7 +19,7 @@ internal static RouteHandlerBuilder MapGetMyInvoicesEndpoint(this IEndpointRoute
periodYear,
periodMonth,
pageNumber <= 0 ? 1 : pageNumber,
- pageSize <= 0 ? 20 : pageSize), ct))
+ pageSize <= 0 ? 20 : Math.Min(pageSize, 100)), ct))
.WithName("GetMyInvoices")
.WithSummary("List invoices for the current tenant");
}
diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/AssignSubscription/AssignSubscriptionCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/AssignSubscription/AssignSubscriptionCommandHandler.cs
index aadac01e40..86a510398b 100644
--- a/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/AssignSubscription/AssignSubscriptionCommandHandler.cs
+++ b/src/Modules/Billing/Modules.Billing/Features/v1/Subscriptions/AssignSubscription/AssignSubscriptionCommandHandler.cs
@@ -1,4 +1,6 @@
+using Finbuckle.MultiTenant.Abstractions;
using FSH.Framework.Core.Exceptions;
+using FSH.Framework.Shared.Multitenancy;
using FSH.Modules.Billing.Contracts.v1.Subscriptions;
using FSH.Modules.Billing.Data;
using FSH.Modules.Billing.Domain;
@@ -7,13 +9,23 @@
namespace FSH.Modules.Billing.Features.v1.Subscriptions.AssignSubscription;
-public sealed class AssignSubscriptionCommandHandler(BillingDbContext dbContext)
+public sealed class AssignSubscriptionCommandHandler(
+ BillingDbContext dbContext,
+ IMultiTenantContextAccessor tenantAccessor)
: ICommandHandler
{
public async ValueTask Handle(AssignSubscriptionCommand command, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(command);
+ // Only the root operator may assign a subscription to an arbitrary tenant. A tenant caller is
+ // pinned to its own tenant, so it can't (re)assign or cancel another tenant's subscription by
+ // passing a foreign tenant id in the body.
+ var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id
+ ?? throw new UnauthorizedException("Tenant context is required.");
+ var isRoot = callerTenantId == MultitenancyConstants.Root.Id;
+ var targetTenantId = isRoot ? command.TenantId : callerTenantId;
+
#pragma warning disable CA1308 // Plan keys are canonical lowercase slugs
var key = command.PlanKey.ToLowerInvariant();
#pragma warning restore CA1308
@@ -22,11 +34,11 @@ public async ValueTask Handle(AssignSubscriptionCommand command, Cancellat
var now = DateTime.UtcNow;
var current = await dbContext.Subscriptions
- .FirstOrDefaultAsync(s => s.TenantId == command.TenantId && s.Status == Contracts.SubscriptionStatus.Active, cancellationToken)
+ .FirstOrDefaultAsync(s => s.TenantId == targetTenantId && s.Status == Contracts.SubscriptionStatus.Active, cancellationToken)
.ConfigureAwait(false);
current?.Cancel(now);
- var subscription = Subscription.Create(command.TenantId, plan.Id, now);
+ var subscription = Subscription.Create(targetTenantId, plan.Id, now);
dbContext.Subscriptions.Add(subscription);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return subscription.Id;
diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Usage/CaptureUsageSnapshots/CaptureUsageSnapshotsCommandHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Usage/CaptureUsageSnapshots/CaptureUsageSnapshotsCommandHandler.cs
index 06c0c7625f..930c39de9c 100644
--- a/src/Modules/Billing/Modules.Billing/Features/v1/Usage/CaptureUsageSnapshots/CaptureUsageSnapshotsCommandHandler.cs
+++ b/src/Modules/Billing/Modules.Billing/Features/v1/Usage/CaptureUsageSnapshots/CaptureUsageSnapshotsCommandHandler.cs
@@ -1,3 +1,6 @@
+using Finbuckle.MultiTenant.Abstractions;
+using FSH.Framework.Core.Exceptions;
+using FSH.Framework.Shared.Multitenancy;
using FSH.Modules.Billing.Contracts.Dtos;
using FSH.Modules.Billing.Contracts.v1.Usage;
using FSH.Modules.Billing.Services;
@@ -5,7 +8,9 @@
namespace FSH.Modules.Billing.Features.v1.Usage.CaptureUsageSnapshots;
-public sealed class CaptureUsageSnapshotsCommandHandler(IUsageReporter reporter)
+public sealed class CaptureUsageSnapshotsCommandHandler(
+ IUsageReporter reporter,
+ IMultiTenantContextAccessor tenantAccessor)
: ICommandHandler>
{
public async ValueTask> Handle(
@@ -13,8 +18,15 @@ public async ValueTask> Handle(
{
ArgumentNullException.ThrowIfNull(command);
+ // Only the root operator may capture usage for an arbitrary tenant; a tenant caller is pinned
+ // to its own tenant so it can't fabricate another tenant's usage/overage snapshots.
+ var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id
+ ?? throw new UnauthorizedException("Tenant context is required.");
+ var isRoot = callerTenantId == MultitenancyConstants.Root.Id;
+ var targetTenantId = isRoot ? command.TenantId : callerTenantId;
+
var snapshots = await reporter
- .CaptureForPeriodAsync(command.TenantId, command.PeriodYear, command.PeriodMonth, cancellationToken)
+ .CaptureForPeriodAsync(targetTenantId, command.PeriodYear, command.PeriodMonth, cancellationToken)
.ConfigureAwait(false);
return snapshots
diff --git a/src/Modules/Billing/Modules.Billing/Features/v1/Usage/GetUsageSnapshots/GetUsageSnapshotsQueryHandler.cs b/src/Modules/Billing/Modules.Billing/Features/v1/Usage/GetUsageSnapshots/GetUsageSnapshotsQueryHandler.cs
index dcd79bca69..a49afdae72 100644
--- a/src/Modules/Billing/Modules.Billing/Features/v1/Usage/GetUsageSnapshots/GetUsageSnapshotsQueryHandler.cs
+++ b/src/Modules/Billing/Modules.Billing/Features/v1/Usage/GetUsageSnapshots/GetUsageSnapshotsQueryHandler.cs
@@ -1,3 +1,6 @@
+using Finbuckle.MultiTenant.Abstractions;
+using FSH.Framework.Core.Exceptions;
+using FSH.Framework.Shared.Multitenancy;
using FSH.Modules.Billing.Contracts.Dtos;
using FSH.Modules.Billing.Contracts.v1.Usage;
using FSH.Modules.Billing.Data;
@@ -6,17 +9,26 @@
namespace FSH.Modules.Billing.Features.v1.Usage.GetUsageSnapshots;
-public sealed class GetUsageSnapshotsQueryHandler(BillingDbContext dbContext)
+public sealed class GetUsageSnapshotsQueryHandler(
+ BillingDbContext dbContext,
+ IMultiTenantContextAccessor tenantAccessor)
: IQueryHandler>
{
public async ValueTask> Handle(GetUsageSnapshotsQuery query, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(query);
+ // UsageSnapshots is not tenant-filtered. Only the root operator may read across tenants
+ // (optionally narrowed via query.TenantId); any other caller is forced to its own tenant.
+ var callerTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id
+ ?? throw new UnauthorizedException("Tenant context is required.");
+ var isRoot = callerTenantId == MultitenancyConstants.Root.Id;
+ var tenantFilter = isRoot ? query.TenantId : callerTenantId;
+
var q = dbContext.UsageSnapshots.AsNoTracking();
- if (!string.IsNullOrWhiteSpace(query.TenantId))
+ if (!string.IsNullOrWhiteSpace(tenantFilter))
{
- q = q.Where(s => s.TenantId == query.TenantId);
+ q = q.Where(s => s.TenantId == tenantFilter);
}
if (query.PeriodYear is not null)
{
diff --git a/src/Modules/Billing/Modules.Billing/IntegrationEventHandlers/TenantRenewedIntegrationEventHandler.cs b/src/Modules/Billing/Modules.Billing/IntegrationEventHandlers/TenantRenewedIntegrationEventHandler.cs
index 63604154b7..e3ec528b64 100644
--- a/src/Modules/Billing/Modules.Billing/IntegrationEventHandlers/TenantRenewedIntegrationEventHandler.cs
+++ b/src/Modules/Billing/Modules.Billing/IntegrationEventHandlers/TenantRenewedIntegrationEventHandler.cs
@@ -27,6 +27,13 @@ public async Task HandleAsync(TenantRenewedIntegrationEvent @event, Cancellation
await TenantSubscriptionMaintenance.ReplaceActiveSubscriptionAsync(
db, tenantId, @event.PlanId, @event.PeriodStartUtc, @event.PeriodEndUtc, ct).ConfigureAwait(false);
}
+ else
+ {
+ // Same-plan renewal: extend the active subscription's term so EndUtc tracks the renewed
+ // ValidUpto (otherwise the dashboard's "Current term"/validity drifts behind enforcement).
+ await TenantSubscriptionMaintenance.ExtendActiveSubscriptionAsync(
+ db, tenantId, @event.PeriodEndUtc, ct).ConfigureAwait(false);
+ }
await billing.CreateSubscriptionInvoiceAsync(
tenantId, @event.PlanId, @event.PeriodStartUtc, @event.PeriodEndUtc, ct).ConfigureAwait(false);
diff --git a/src/Modules/Billing/Modules.Billing/IntegrationEventHandlers/TenantSubscriptionMaintenance.cs b/src/Modules/Billing/Modules.Billing/IntegrationEventHandlers/TenantSubscriptionMaintenance.cs
index 4adb4ca64c..b0d293b93a 100644
--- a/src/Modules/Billing/Modules.Billing/IntegrationEventHandlers/TenantSubscriptionMaintenance.cs
+++ b/src/Modules/Billing/Modules.Billing/IntegrationEventHandlers/TenantSubscriptionMaintenance.cs
@@ -27,4 +27,27 @@ public static async Task ReplaceActiveSubscriptionAsync(
db.Subscriptions.Add(Subscription.Create(tenantId, planId, startUtc, endUtc));
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
+
+ ///
+ /// Same-plan renewal: extend the active subscription's end so Subscription.EndUtc stays in
+ /// step with the tenant's renewed ValidUpto (otherwise the dashboard's subscription term
+ /// drifts behind the enforced validity). Idempotent via .
+ ///
+ public static async Task ExtendActiveSubscriptionAsync(
+ BillingDbContext db,
+ string tenantId,
+ DateTime endUtc,
+ CancellationToken cancellationToken)
+ {
+ var active = await db.Subscriptions
+ .FirstOrDefaultAsync(s => s.TenantId == tenantId && s.Status == SubscriptionStatus.Active, cancellationToken)
+ .ConfigureAwait(false);
+ if (active is null)
+ {
+ return;
+ }
+
+ active.Extend(endUtc);
+ await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ }
}
diff --git a/src/Modules/Billing/Modules.Billing/Services/BillingService.cs b/src/Modules/Billing/Modules.Billing/Services/BillingService.cs
index f8b5c53e6c..5022d2b792 100644
--- a/src/Modules/Billing/Modules.Billing/Services/BillingService.cs
+++ b/src/Modules/Billing/Modules.Billing/Services/BillingService.cs
@@ -17,6 +17,7 @@ public sealed class BillingService : IBillingService
private readonly BillingDbContext _db;
private readonly IUsageReporter _usageReporter;
private readonly IMultiTenantStore _tenantStore;
+ private readonly IMultiTenantContextAccessor _tenantAccessor;
private readonly IEventBus _eventBus;
private readonly TimeProvider _timeProvider;
private readonly ILogger _logger;
@@ -25,6 +26,7 @@ public BillingService(
BillingDbContext db,
IUsageReporter usageReporter,
IMultiTenantStore tenantStore,
+ IMultiTenantContextAccessor tenantAccessor,
IEventBus eventBus,
TimeProvider timeProvider,
ILogger logger)
@@ -32,6 +34,7 @@ public BillingService(
_db = db;
_usageReporter = usageReporter;
_tenantStore = tenantStore;
+ _tenantAccessor = tenantAccessor;
_eventBus = eventBus;
_timeProvider = timeProvider;
_logger = logger;
@@ -45,14 +48,19 @@ public BillingService(
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
+ // Scope idempotency to the Usage invoice only. The unique index is
+ // (TenantId, PeriodYear, PeriodMonth, Purpose), so a Subscription invoice can legitimately
+ // share the month — without the Purpose filter the existence check would find that
+ // subscription invoice and skip generating the usage/overage invoice (unbilled overage).
var existing = await _db.Invoices
- .FirstOrDefaultAsync(i => i.TenantId == tenantId && i.PeriodYear == periodYear && i.PeriodMonth == periodMonth, cancellationToken)
+ .FirstOrDefaultAsync(i => i.TenantId == tenantId && i.PeriodYear == periodYear && i.PeriodMonth == periodMonth
+ && i.Purpose == InvoicePurpose.Usage, cancellationToken)
.ConfigureAwait(false);
if (existing is not null)
{
if (_logger.IsEnabled(LogLevel.Information))
{
- _logger.LogInformation("[Billing] invoice already exists for tenant {TenantId} period {Year}-{Month:00}, skipping",
+ _logger.LogInformation("[Billing] usage invoice already exists for tenant {TenantId} period {Year}-{Month:00}, skipping",
tenantId, periodYear, periodMonth);
}
return existing;
@@ -122,7 +130,8 @@ public async Task GenerateInvoicesForAllTenantsAsync(
.ConfigureAwait(false);
var alreadyInvoiced = await _db.Invoices
- .Where(i => i.PeriodYear == periodYear && i.PeriodMonth == periodMonth && subscribedTenantIds.Contains(i.TenantId))
+ .Where(i => i.PeriodYear == periodYear && i.PeriodMonth == periodMonth
+ && i.Purpose == InvoicePurpose.Usage && subscribedTenantIds.Contains(i.TenantId))
.Select(i => i.TenantId)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
@@ -171,9 +180,20 @@ public async Task VoidInvoiceAsync(Guid invoiceId, string? reason, CancellationT
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
- private async Task LoadInvoiceAsync(Guid invoiceId, CancellationToken cancellationToken) =>
- await _db.Invoices.FirstOrDefaultAsync(i => i.Id == invoiceId, cancellationToken).ConfigureAwait(false)
+ // Issue/MarkPaid/Void load through here. BillingDbContext is not tenant-filtered, so scope to the
+ // caller: the root operator may mutate any tenant's invoice; a tenant caller is pinned to its own,
+ // so a cross-tenant id resolves to 404 (mirrors GetInvoiceByIdQueryHandler) and can't be mutated.
+ private async Task LoadInvoiceAsync(Guid invoiceId, CancellationToken cancellationToken)
+ {
+ var callerTenantId = _tenantAccessor.MultiTenantContext?.TenantInfo?.Id
+ ?? throw new UnauthorizedException("Tenant context is required.");
+ var isRoot = callerTenantId == MultitenancyConstants.Root.Id;
+
+ return await _db.Invoices
+ .FirstOrDefaultAsync(i => i.Id == invoiceId && (isRoot || i.TenantId == callerTenantId), cancellationToken)
+ .ConfigureAwait(false)
?? throw new NotFoundException($"Invoice {invoiceId} not found.");
+ }
public async Task CreateSubscriptionInvoiceAsync(
string tenantId,
diff --git a/src/Modules/Identity/Modules.Identity/Data/IdentityDbInitializer.cs b/src/Modules/Identity/Modules.Identity/Data/IdentityDbInitializer.cs
index e976109f85..911d812ebd 100644
--- a/src/Modules/Identity/Modules.Identity/Data/IdentityDbInitializer.cs
+++ b/src/Modules/Identity/Modules.Identity/Data/IdentityDbInitializer.cs
@@ -210,7 +210,17 @@ private async Task SeedAdminUserAsync(CancellationToken cancellationToken = defa
var initialPassword = ResolveInitialAdminPassword(multiTenantContextAccessor.MultiTenantContext.TenantInfo!.Id!);
var password = new PasswordHasher();
adminUser.PasswordHash = password.HashPassword(adminUser, initialPassword);
- await userManager.CreateAsync(adminUser);
+ // The IdentityResult MUST be checked: a silent failure here (e.g. a password-policy
+ // rejection or a transient DB error) would otherwise mark provisioning "Completed" with no
+ // admin user — an unrecoverable tenant with no login. Throwing surfaces it as a Failed
+ // provisioning step that the operator can retry.
+ var createResult = await userManager.CreateAsync(adminUser);
+ if (!createResult.Succeeded)
+ {
+ throw new InvalidOperationException(
+ $"Failed to seed admin user for tenant '{multiTenantContextAccessor.MultiTenantContext.TenantInfo!.Id}': "
+ + string.Join("; ", createResult.Errors.Select(e => e.Description)));
+ }
}
// Assign role to user
diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs
index 0735fe895e..2044408e5d 100644
--- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs
+++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/RoleService.cs
@@ -192,10 +192,18 @@ private static void EnsureNotSystemRole(string? roleName, string message)
private void FilterRootPermissions(List permissions)
{
- if (multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id != MultitenancyConstants.Root.Id)
+ if (multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id == MultitenancyConstants.Root.Id)
{
- permissions.RemoveAll(u => u.StartsWith("Permissions.Root.", StringComparison.InvariantCultureIgnoreCase));
+ // The root operator may manage root-only permissions.
+ return;
}
+
+ // Strip every permission flagged IsRoot in the registry. The previous check removed only names
+ // starting with "Permissions.Root." — but NO root permission uses that prefix (they are
+ // Permissions.Tenants.* / Permissions.Platform.*, flagged via IsRoot), so the filter was a
+ // no-op and a non-root tenant admin with Roles.Update could grant their role root permissions.
+ var rootOnly = PermissionConstants.Root.Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
+ permissions.RemoveAll(rootOnly.Contains);
}
private async Task RemoveRevokedPermissionsAsync(FshRole role, IList currentClaims, List permissions, CancellationToken cancellationToken = default)
diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/AdjustTenantValidity/AdjustTenantValidityCommandValidator.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/AdjustTenantValidity/AdjustTenantValidityCommandValidator.cs
index 96d6e7f544..cf7d7a5282 100644
--- a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/AdjustTenantValidity/AdjustTenantValidityCommandValidator.cs
+++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/AdjustTenantValidity/AdjustTenantValidityCommandValidator.cs
@@ -1,4 +1,5 @@
using FluentValidation;
+using FSH.Framework.Shared.Multitenancy;
using FSH.Modules.Multitenancy.Contracts.v1.AdjustTenantValidity;
namespace FSH.Modules.Multitenancy.Features.v1.AdjustTenantValidity;
@@ -9,6 +10,12 @@ public AdjustTenantValidityCommandValidator()
{
RuleFor(t => t.TenantId).NotEmpty();
+ // The root operator tenant must never expire — block adjusting its validity (mirrors the
+ // Activate/Deactivate guards that already refuse the root tenant).
+ RuleFor(t => t.TenantId)
+ .Must(id => !string.Equals(id, MultitenancyConstants.Root.Id, StringComparison.Ordinal))
+ .WithMessage("The root operator tenant's validity cannot be adjusted.");
+
RuleFor(t => t.ValidUpto)
.Must(d => d != default)
.WithMessage("A valid 'validUpto' date is required.");
diff --git a/src/Tests/Billing.Tests/Domain/InvoiceTests.cs b/src/Tests/Billing.Tests/Domain/InvoiceTests.cs
index d62d739603..5104146b21 100644
--- a/src/Tests/Billing.Tests/Domain/InvoiceTests.cs
+++ b/src/Tests/Billing.Tests/Domain/InvoiceTests.cs
@@ -148,6 +148,20 @@ public void Void_Should_Combine_Reason_With_Existing_Notes()
inv.Notes.ShouldBe("original note; Voided: mistake");
}
+ [Fact]
+ public void Void_Should_Be_Idempotent_When_Already_Void()
+ {
+ var inv = NewDraft();
+ inv.Void("duplicate");
+ var firstVoidedAt = inv.VoidedAtUtc;
+
+ inv.Void("second attempt");
+
+ inv.Status.ShouldBe(InvoiceStatus.Void);
+ inv.VoidedAtUtc.ShouldBe(firstVoidedAt, "re-voiding must not re-stamp VoidedAtUtc");
+ inv.Notes.ShouldBe("duplicate", "re-voiding must not append the reason again");
+ }
+
#endregion
#region Exceptions
diff --git a/src/Tests/Integration.Tests/Infrastructure/Extensions/HttpResponseExtensions.cs b/src/Tests/Integration.Tests/Infrastructure/Extensions/HttpResponseExtensions.cs
index dfee8bc82c..9c3b3fc14a 100644
--- a/src/Tests/Integration.Tests/Infrastructure/Extensions/HttpResponseExtensions.cs
+++ b/src/Tests/Integration.Tests/Infrastructure/Extensions/HttpResponseExtensions.cs
@@ -1,4 +1,5 @@
using System.Text.Json;
+using System.Text.Json.Serialization;
namespace Integration.Tests.Infrastructure.Extensions;
@@ -7,7 +8,10 @@ public static class HttpResponseExtensions
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- PropertyNameCaseInsensitive = true
+ PropertyNameCaseInsensitive = true,
+ // The API serializes enums as string names (global JsonStringEnumConverter); the converter
+ // also reads them back, so DTOs with enum fields (e.g. BillingPlanDto.Interval) deserialize.
+ Converters = { new JsonStringEnumConverter() }
};
public static async Task DeserializeAsync(this HttpResponseMessage response, CancellationToken ct = default)
diff --git a/src/Tests/Integration.Tests/Tests/Auditing/AuditPayloadFilterTests.cs b/src/Tests/Integration.Tests/Tests/Auditing/AuditPayloadFilterTests.cs
index 28f80a1f6f..15305bb048 100644
--- a/src/Tests/Integration.Tests/Tests/Auditing/AuditPayloadFilterTests.cs
+++ b/src/Tests/Integration.Tests/Tests/Auditing/AuditPayloadFilterTests.cs
@@ -26,7 +26,9 @@ public sealed class AuditPayloadFilterTests
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- PropertyNameCaseInsensitive = true
+ PropertyNameCaseInsensitive = true,
+ // API serializes enums as string names (global JsonStringEnumConverter); read them back.
+ Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
};
private readonly FshWebApplicationFactory _factory;
diff --git a/src/Tests/Integration.Tests/Tests/Billing/BillingTenantIsolationTests.cs b/src/Tests/Integration.Tests/Tests/Billing/BillingTenantIsolationTests.cs
index 82c9898379..9433b46d25 100644
--- a/src/Tests/Integration.Tests/Tests/Billing/BillingTenantIsolationTests.cs
+++ b/src/Tests/Integration.Tests/Tests/Billing/BillingTenantIsolationTests.cs
@@ -1,8 +1,10 @@
+using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
using Finbuckle.MultiTenant;
using Finbuckle.MultiTenant.Abstractions;
using FSH.Framework.Shared.Multitenancy;
+using FSH.Framework.Shared.Quota;
using FSH.Modules.Billing.Contracts;
using FSH.Modules.Billing.Contracts.Dtos;
using FSH.Modules.Billing.Data;
@@ -138,6 +140,173 @@ public async Task GetSubscription_Should_NotLeak_When_OtherTenantPassesOwnersTen
#endregion
+ #region Cross-tenant mutation isolation (P0 security regressions)
+
+ // A non-root tenant must never be able to MUTATE another tenant's billing resources by id —
+ // the read-side isolation above is necessary but not sufficient. Each test below encodes the
+ // exact attack: tenant B (fresh, non-root) targets root's resource. Pre-fix these succeeded.
+
+ [Fact]
+ public async Task VoidInvoice_Should_Return404_When_OwnedByDifferentTenant()
+ {
+ var (year, month) = NextPeriod();
+ var invoiceId = await SeedDraftInvoiceAsync(TestConstants.RootTenantId, year, month, basePrice: 77.00m);
+
+ using var rootClient = await _auth.CreateRootAdminClientAsync();
+ var (otherClient, _) = await CreateForeignTenantClientAsync(rootClient, "void");
+ using var _o = otherClient;
+
+ // Tenant B tries to void root's invoice → must 404, and the invoice must stay Draft.
+ using var crossResp = await otherClient.PostAsJsonAsync(
+ $"{BillingBasePath}/invoices/{invoiceId}/void", new { reason = "malicious" });
+ crossResp.StatusCode.ShouldBe(HttpStatusCode.NotFound,
+ "a tenant must not be able to void another tenant's invoice by id");
+
+ using var ownerRead = await rootClient.GetAsync($"{BillingBasePath}/invoices/{invoiceId}");
+ var dto = await ParseAsync(ownerRead);
+ dto.Status.ShouldBe(InvoiceStatus.Draft, "the cross-tenant void attempt must not have mutated the invoice");
+
+ // The owner (root) CAN void it.
+ using var ownerVoid = await rootClient.PostAsJsonAsync(
+ $"{BillingBasePath}/invoices/{invoiceId}/void", new { reason = "ok" });
+ ownerVoid.StatusCode.ShouldBe(HttpStatusCode.OK);
+ }
+
+ [Fact]
+ public async Task AssignSubscription_Should_NotAffect_OtherTenant_When_NonRootPassesForeignTenantId()
+ {
+ using var rootClient = await _auth.CreateRootAdminClientAsync();
+ var key = UniqueKey("assign");
+ await CreatePlanAsync(rootClient, key, name: "Plan-assign", monthlyBasePrice: 9m);
+ var rootSubId = await AssignSubscriptionAsync(rootClient, TestConstants.RootTenantId, key);
+
+ var (otherClient, _) = await CreateForeignTenantClientAsync(rootClient, "assign");
+ using var _o = otherClient;
+
+ // Tenant B tries to (re)assign ROOT's subscription by passing root's tenant id in the body.
+ // The handler must pin B to its own tenant, so root's active subscription is untouched.
+ using var resp = await otherClient.PostAsJsonAsync(
+ $"{BillingBasePath}/subscriptions", new { tenantId = TestConstants.RootTenantId, planKey = key });
+
+ var rootSubAfter = await GetSubscriptionAsync(rootClient, TestConstants.RootTenantId);
+ rootSubAfter.ShouldNotBeNull();
+ rootSubAfter!.Id.ShouldBe(rootSubId,
+ "a tenant must not be able to cancel/replace root's subscription via a foreign tenant id");
+ }
+
+ [Fact]
+ public async Task GetUsage_Should_NotLeak_OtherTenants_Snapshots()
+ {
+ var (year, month) = NextPeriod();
+ const long Marker = 918273645; // distinctive usedUnits value to spot a leak in the raw JSON
+ await SeedDirectAsync(TestConstants.RootTenantId, async db =>
+ {
+ db.UsageSnapshots.Add(UsageSnapshot.Capture(
+ TestConstants.RootTenantId, year, month, QuotaResource.ApiCalls, usedUnits: Marker, limitUnits: 1000));
+ await db.SaveChangesAsync();
+ });
+
+ using var rootClient = await _auth.CreateRootAdminClientAsync();
+ var (otherClient, _) = await CreateForeignTenantClientAsync(rootClient, "usage");
+ using var _o = otherClient;
+ var markerText = Marker.ToString(CultureInfo.InvariantCulture);
+
+ // B with no filter must not see root's snapshot.
+ using var noFilter = await otherClient.GetAsync($"{BillingBasePath}/usage");
+ noFilter.StatusCode.ShouldBe(HttpStatusCode.OK);
+ (await noFilter.Content.ReadAsStringAsync()).Contains(markerText, StringComparison.Ordinal)
+ .ShouldBeFalse("a tenant must not see another tenant's usage snapshots");
+
+ // B explicitly passing root's tenant id must STILL be scoped to B (no bypass).
+ using var withRootId = await otherClient.GetAsync($"{BillingBasePath}/usage?tenantId={TestConstants.RootTenantId}");
+ withRootId.StatusCode.ShouldBe(HttpStatusCode.OK);
+ (await withRootId.Content.ReadAsStringAsync()).Contains(markerText, StringComparison.Ordinal)
+ .ShouldBeFalse("passing another tenant's id must not bypass tenant scoping");
+
+ // Root can read its own.
+ using var rootRead = await rootClient.GetAsync($"{BillingBasePath}/usage?tenantId={TestConstants.RootTenantId}");
+ (await rootRead.Content.ReadAsStringAsync()).Contains(markerText, StringComparison.Ordinal)
+ .ShouldBeTrue("the owning tenant must see its own usage snapshot");
+ }
+
+ [Fact]
+ public async Task CaptureUsage_Should_BeScopedToCaller_When_NonRootPassesForeignTenantId()
+ {
+ var (year, month) = NextPeriod();
+ using var rootClient = await _auth.CreateRootAdminClientAsync();
+ var (otherClient, _) = await CreateForeignTenantClientAsync(rootClient, "capture");
+ using var _o = otherClient;
+
+ // B asks to capture usage FOR ROOT. The handler must pin to B, so nothing it returns/writes
+ // belongs to root.
+ using var resp = await otherClient.PostAsJsonAsync(
+ $"{BillingBasePath}/usage/snapshots/capture",
+ new { tenantId = TestConstants.RootTenantId, periodYear = year, periodMonth = month });
+ resp.StatusCode.ShouldBe(HttpStatusCode.OK, await resp.Content.ReadAsStringAsync());
+
+ var snaps = await ParseAsync>(resp);
+ snaps.ShouldAllBe(s => s.TenantId != TestConstants.RootTenantId,
+ "a tenant capturing usage must be scoped to itself — never able to write another tenant's usage");
+ }
+
+ [Fact]
+ public async Task GenerateInvoices_Should_BeForbidden_For_NonRootTenant()
+ {
+ var (year, month) = NextPeriod();
+ using var rootClient = await _auth.CreateRootAdminClientAsync();
+ var (otherClient, _) = await CreateForeignTenantClientAsync(rootClient, "generate");
+ using var _o = otherClient;
+
+ using var crossResp = await otherClient.PostAsJsonAsync(
+ $"{BillingBasePath}/invoices/generate", new { periodYear = year, periodMonth = month });
+ crossResp.StatusCode.ShouldBe(HttpStatusCode.Forbidden,
+ "platform-wide invoice generation must be root-operator only");
+
+ using var rootResp = await rootClient.PostAsJsonAsync(
+ $"{BillingBasePath}/invoices/generate", new { periodYear = year, periodMonth = month });
+ rootResp.StatusCode.ShouldBe(HttpStatusCode.OK);
+ }
+
+ #endregion
+
+ #region Usage-invoice generation correctness (P1)
+
+ [Fact]
+ public async Task GenerateInvoiceForPeriod_Should_CreateUsageInvoice_EvenWhen_SubscriptionInvoiceSharesThePeriod()
+ {
+ // Reproduces the revenue bug: the idempotency check used to match ANY invoice for the period
+ // (no Purpose), so when a Subscription invoice already existed for the month the Usage/overage
+ // invoice was silently skipped. Seed an active subscription + a Subscription invoice, then
+ // generate — a Usage invoice must still be produced.
+ var (year, month) = NextPeriod();
+ using var rootClient = await _auth.CreateRootAdminClientAsync();
+ var key = UniqueKey("usgskip");
+ await CreatePlanAsync(rootClient, key, name: "Plan-usgskip", monthlyBasePrice: 15m);
+ await AssignSubscriptionAsync(rootClient, TestConstants.RootTenantId, key);
+
+ await SeedDirectAsync(TestConstants.RootTenantId, async db =>
+ {
+ var subInvoice = Invoice.CreateDraft(
+ TestConstants.RootTenantId,
+ $"SUB-SEED-{year}{month:00}-{Guid.NewGuid().ToString("N")[..6].ToUpperInvariant()}",
+ year, month, "USD", InvoicePurpose.Subscription, periodStartUtc: null, periodEndUtc: null);
+ subInvoice.AddLineItem(InvoiceLineItemKind.BaseFee, "Seeded subscription fee", 1m, 15m);
+ db.Invoices.Add(subInvoice);
+ await db.SaveChangesAsync();
+ });
+
+ // Invoke the generator directly for this exact period (tenant context = root). Pre-fix the
+ // idempotency check matched the seeded SUBSCRIPTION invoice and returned it (Purpose=Subscription),
+ // skipping usage billing. Post-fix it must produce a USAGE invoice.
+ var generated = await InvokeGenerateInvoiceForPeriodAsync(TestConstants.RootTenantId, year, month);
+
+ generated.ShouldNotBeNull("the generator must produce an invoice when an active subscription exists");
+ generated!.Purpose.ShouldBe(InvoicePurpose.Usage,
+ "the usage invoice must be generated even when a subscription invoice already shares the month");
+ }
+
+ #endregion
+
#region helpers
/// Plan keys are lowercased + unique-indexed; this helper guarantees no collisions.
@@ -256,6 +425,29 @@ private async Task CreateTenantAdminClientWithRetryAsync(
return await _auth.CreateAuthenticatedClientAsync(email, password, tenant);
}
+ /// Provisions a fresh non-root tenant and returns an authenticated admin client for it.
+ private async Task<(HttpClient Client, string TenantId)> CreateForeignTenantClientAsync(HttpClient rootClient, string label)
+ {
+ var uniqueId = Guid.NewGuid().ToString("N")[..8];
+ var tenantId = $"billing-{label}-iso-{uniqueId}";
+ var adminEmail = $"billing-{label}-admin-{uniqueId}@tenant.com";
+ await CreateTenantAsync(rootClient, tenantId, adminEmail);
+ await WaitForProvisioningAsync(rootClient, tenantId);
+ var client = await CreateTenantAdminClientWithRetryAsync(adminEmail, TestConstants.DefaultPassword, tenantId);
+ return (client, tenantId);
+ }
+
+ /// Invokes the billing generator for one tenant/period directly, under that tenant's context.
+ private async Task InvokeGenerateInvoiceForPeriodAsync(string tenantId, int year, int month)
+ {
+ using var scope = _factory.Services.CreateScope();
+ var tenant = await scope.ServiceProvider.GetRequiredService>().GetAsync(tenantId);
+ scope.ServiceProvider.GetRequiredService().MultiTenantContext =
+ new MultiTenantContext(tenant);
+ var billing = scope.ServiceProvider.GetRequiredService();
+ return await billing.GenerateInvoiceForPeriodAsync(tenantId, year, month);
+ }
+
private static async Task CreateTenantAsync(HttpClient rootClient, string tenantId, string adminEmail)
{
var response = await rootClient.PostAsJsonAsync(TestConstants.TenantsBasePath, new
diff --git a/src/Tests/Integration.Tests/Tests/Billing/InvoicePdfTests.cs b/src/Tests/Integration.Tests/Tests/Billing/InvoicePdfTests.cs
index 2deb80a1f0..56c9610385 100644
--- a/src/Tests/Integration.Tests/Tests/Billing/InvoicePdfTests.cs
+++ b/src/Tests/Integration.Tests/Tests/Billing/InvoicePdfTests.cs
@@ -6,8 +6,9 @@ namespace Integration.Tests.Tests.Billing;
///
/// Coverage for the invoice PDF download endpoint (GET /api/v1/billing/invoices/{id}/pdf):
-/// a tenant can download its own invoice as application/pdf, and the fetch is scoped to the caller's
-/// tenant so another tenant's invoice id resolves to 404 (no cross-tenant leak).
+/// the OWNER tenant and the ROOT operator can download an invoice as application/pdf, but a different
+/// (non-root) tenant's request for that invoice id resolves to 404 — no cross-tenant leak. (The root
+/// operator's cross-tenant download backs the admin console's "Download PDF" action.)
///
[Collection(FshCollectionDefinition.Name)]
public sealed class InvoicePdfTests
@@ -22,7 +23,7 @@ public InvoicePdfTests(FshWebApplicationFactory factory)
}
[Fact]
- public async Task Tenant_Should_Download_Own_Invoice_AsPdf_And_NotAnotherTenants()
+ public async Task InvoicePdf_Should_Be_Downloadable_By_Owner_And_RootOperator_But_Not_AnotherTenant()
{
using var rootClient = await _auth.CreateRootAdminClientAsync();
var unique = Guid.NewGuid().ToString("N")[..8];
@@ -37,7 +38,7 @@ public async Task Tenant_Should_Download_Own_Invoice_AsPdf_And_NotAnotherTenants
using var tenantClient = await CreateTenantAdminClientWithRetryAsync(adminEmail, TestConstants.DefaultPassword, tenantId);
- // Own invoice → 200 application/pdf with a real PDF body.
+ // Owner → 200 application/pdf with a real PDF body.
using var ownResponse = await tenantClient.GetAsync($"{BillingBasePath}/invoices/{invoiceId}/pdf");
ownResponse.StatusCode.ShouldBe(HttpStatusCode.OK, await ownResponse.Content.ReadAsStringAsync());
ownResponse.Content.Headers.ContentType?.MediaType.ShouldBe("application/pdf");
@@ -45,9 +46,23 @@ public async Task Tenant_Should_Download_Own_Invoice_AsPdf_And_NotAnotherTenants
bytes.Length.ShouldBeGreaterThan(0);
Encoding.ASCII.GetString(bytes, 0, 4).ShouldBe("%PDF");
- // Cross-tenant: the root operator's own context has no such invoice → 404 (no leak).
- using var crossResponse = await rootClient.GetAsync($"{BillingBasePath}/invoices/{invoiceId}/pdf");
- crossResponse.StatusCode.ShouldBe(HttpStatusCode.NotFound);
+ // Root operator → 200: the operator may download ANY tenant's invoice (admin "Download PDF").
+ using var rootResponse = await rootClient.GetAsync($"{BillingBasePath}/invoices/{invoiceId}/pdf");
+ rootResponse.StatusCode.ShouldBe(HttpStatusCode.OK,
+ "the root operator must be able to download any tenant's invoice PDF");
+
+ // A DIFFERENT non-root tenant → 404: cross-tenant access is denied (no leak).
+ var otherUnique = Guid.NewGuid().ToString("N")[..8];
+ var otherTenantId = $"pdf-other-{otherUnique}";
+ var otherEmail = $"pdf-other-{otherUnique}@tenant.com";
+ await CreateTenantAsync(rootClient, otherTenantId, otherEmail, planKey);
+ await WaitForProvisioningAsync(rootClient, otherTenantId);
+ using var otherClient = await CreateTenantAdminClientWithRetryAsync(
+ otherEmail, TestConstants.DefaultPassword, otherTenantId);
+
+ using var crossResponse = await otherClient.GetAsync($"{BillingBasePath}/invoices/{invoiceId}/pdf");
+ crossResponse.StatusCode.ShouldBe(HttpStatusCode.NotFound,
+ "a tenant must not be able to download another tenant's invoice PDF");
}
private static async Task GetFirstInvoiceIdAsync(HttpClient client, string tenantId)
diff --git a/src/Tests/Integration.Tests/Tests/Billing/TenantBillingLifecycleTests.cs b/src/Tests/Integration.Tests/Tests/Billing/TenantBillingLifecycleTests.cs
index 81802052f2..b81fd7ec60 100644
--- a/src/Tests/Integration.Tests/Tests/Billing/TenantBillingLifecycleTests.cs
+++ b/src/Tests/Integration.Tests/Tests/Billing/TenantBillingLifecycleTests.cs
@@ -22,6 +22,8 @@ public sealed class TenantBillingLifecycleTests
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
+ // API serializes enums as string names (global JsonStringEnumConverter); read them back.
+ Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
};
private readonly AuthHelper _auth;
diff --git a/src/Tests/Integration.Tests/Tests/Multitenancy/AdjustTenantValidityTests.cs b/src/Tests/Integration.Tests/Tests/Multitenancy/AdjustTenantValidityTests.cs
index 54e28632bf..11f33f64de 100644
--- a/src/Tests/Integration.Tests/Tests/Multitenancy/AdjustTenantValidityTests.cs
+++ b/src/Tests/Integration.Tests/Tests/Multitenancy/AdjustTenantValidityTests.cs
@@ -98,6 +98,21 @@ public async Task AdjustValidity_Should_Return400_When_RouteIdDoesNotMatchBody()
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
}
+ [Fact]
+ public async Task AdjustValidity_Should_Return400_When_TargetIsRootTenant()
+ {
+ // The root operator tenant must never expire — adjusting its validity is rejected so an
+ // operator can't accidentally backdate the platform tenant into an expired state.
+ using var rootClient = await _auth.CreateRootAdminClientAsync();
+
+ var response = await rootClient.PostAsJsonAsync(
+ $"{TestConstants.TenantsBasePath}/{TestConstants.RootTenantId}/adjust-validity",
+ new { tenantId = TestConstants.RootTenantId, validUpto = DateTime.UtcNow.AddDays(-1) });
+
+ response.StatusCode.ShouldBe(HttpStatusCode.BadRequest,
+ "the root operator tenant's validity must not be adjustable");
+ }
+
[Fact]
public async Task AdjustValidity_Should_Return401_When_NotAuthenticated()
{
diff --git a/src/Tests/Integration.Tests/Tests/Multitenancy/RenewTenantTests.cs b/src/Tests/Integration.Tests/Tests/Multitenancy/RenewTenantTests.cs
index a41e1597a8..1c579cb78b 100644
--- a/src/Tests/Integration.Tests/Tests/Multitenancy/RenewTenantTests.cs
+++ b/src/Tests/Integration.Tests/Tests/Multitenancy/RenewTenantTests.cs
@@ -130,6 +130,33 @@ public async Task RenewTenant_Should_StartFromNow_When_TenantHasLapsed()
result.ValidUpto.ShouldBeLessThan(DateTime.UtcNow.AddDays(32));
}
+ [Fact]
+ public async Task RenewTenant_Should_Advance_SubscriptionEndUtc_On_SamePlanRenewal()
+ {
+ // Regression for billing/tenant drift: a SAME-PLAN renewal advanced tenant.ValidUpto (which
+ // enforcement uses) but left Subscription.EndUtc (which the dashboard term reads) untouched —
+ // so the two diverged after every renewal. Both must move together.
+ using var rootClient = await _auth.CreateRootAdminClientAsync();
+ var unique = Guid.NewGuid().ToString("N")[..8];
+ 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);
+
+ var before = await GetSubscriptionEndUtcAsync(rootClient, tenantId);
+ before.ShouldNotBeNull("a plan-bound tenant has an active subscription with an end date");
+
+ var result = await RenewAsync(rootClient, tenantId, planKey);
+ result.PlanChanged.ShouldBeFalse("renewing the same plan does not change it");
+
+ var after = await PollSubscriptionEndUtcAdvancedAsync(rootClient, tenantId, before!.Value);
+ after.ShouldNotBeNull();
+ after!.Value.ShouldBeGreaterThan(before.Value,
+ "a same-plan renewal must extend Subscription.EndUtc, not just tenant ValidUpto");
+ after.Value.ShouldBe(result.ValidUpto, tolerance: TimeSpan.FromSeconds(5),
+ "the subscription term should track the renewed validity");
+ }
+
#endregion
#region Validation / Bad Request
@@ -299,6 +326,40 @@ private static async Task WaitForProvisioningAsync(HttpClient client, string ten
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}");
+ resp.StatusCode.ShouldBe(HttpStatusCode.OK);
+ var json = await resp.Content.ReadAsStringAsync();
+ if (string.IsNullOrWhiteSpace(json) || json == "null")
+ {
+ return null;
+ }
+ return JsonSerializer.Deserialize(json, Json)?.EndUtc;
+ }
+
+ // The subscription extension is applied by the renewal integration handler; allow a brief window
+ // in case dispatch is not perfectly synchronous with the renew response.
+ private static async Task PollSubscriptionEndUtcAdvancedAsync(
+ HttpClient client, string tenantId, DateTime baseline, int maxRetries = 20)
+ {
+ for (var i = 0; i < maxRetries; i++)
+ {
+ var end = await GetSubscriptionEndUtcAsync(client, tenantId);
+ if (end is { } e && e > baseline)
+ {
+ return end;
+ }
+ await Task.Delay(500);
+ }
+ return await GetSubscriptionEndUtcAsync(client, tenantId);
+ }
+
+ private sealed record SubRow
+ {
+ public DateTime? EndUtc { get; init; }
+ }
+
private sealed record RenewResult(string TenantId, DateTime ValidUpto, string PlanKey, bool PlanChanged);
private sealed record TenantStatus
diff --git a/src/Tests/Integration.Tests/Tests/Roles/RolePrivilegeEscalationTests.cs b/src/Tests/Integration.Tests/Tests/Roles/RolePrivilegeEscalationTests.cs
new file mode 100644
index 0000000000..6095b5812f
--- /dev/null
+++ b/src/Tests/Integration.Tests/Tests/Roles/RolePrivilegeEscalationTests.cs
@@ -0,0 +1,153 @@
+using FSH.Modules.Identity.Contracts.Authorization;
+using Integration.Tests.Infrastructure;
+using Integration.Tests.Infrastructure.Extensions;
+
+namespace Integration.Tests.Tests.Roles;
+
+///
+/// Privilege-escalation guard. A NON-root tenant admin legitimately holds Roles.Create/Update, but
+/// must NOT be able to grant ROOT-only permissions (Permissions.Tenants.* / Permissions.Platform.*)
+/// to a role. RoleService.FilterRootPermissions previously stripped only names with a
+/// "Permissions.Root." prefix — which matches NO real root permission — so the root permission was
+/// persisted and the caller could escalate to operating on every tenant. These tests encode that
+/// exact attack and the root-operator's legitimate counterpart.
+///
+[Collection(FshCollectionDefinition.Name)]
+public sealed class RolePrivilegeEscalationTests
+{
+ // A real IsRoot permission (MultitenancyPermissions.Tenants.Update — flagged IsRoot: true).
+ private const string RootPermission = "Permissions.Tenants.Update";
+ private const string AllowedPermission = IdentityPermissions.Groups.View;
+
+ private readonly AuthHelper _auth;
+
+ public RolePrivilegeEscalationTests(FshWebApplicationFactory factory)
+ {
+ _auth = new AuthHelper(factory);
+ }
+
+ [Fact]
+ public async Task UpdateRolePermissions_Should_StripRootPermissions_When_CallerIsNonRootTenantAdmin()
+ {
+ using var rootClient = await _auth.CreateRootAdminClientAsync();
+ var unique = Guid.NewGuid().ToString("N")[..8];
+ var tenantId = $"esc-{unique}";
+ var adminEmail = $"esc-admin-{unique}@tenant.com";
+ await CreateTenantAsync(rootClient, tenantId, adminEmail);
+ await WaitForProvisioningAsync(rootClient, tenantId);
+
+ using var tenantAdmin = await CreateTenantAdminClientWithRetryAsync(
+ adminEmail, TestConstants.DefaultPassword, tenantId);
+
+ // The tenant admin creates a role and attempts to grant it a ROOT-only permission alongside a
+ // legitimate non-root one.
+ var roleId = await CreateRoleAsync(tenantAdmin, $"EscRole-{unique}");
+ await SetRolePermissionsAsync(tenantAdmin, roleId, RootPermission, AllowedPermission);
+
+ var persisted = await GetRolePermissionsBodyAsync(tenantAdmin, roleId);
+ persisted.Contains(RootPermission, StringComparison.Ordinal).ShouldBeFalse(
+ "a non-root tenant admin must not be able to grant a root-only permission to a role");
+ persisted.Contains(AllowedPermission, StringComparison.Ordinal).ShouldBeTrue(
+ "non-root permissions in the same request must still be applied");
+ }
+
+ [Fact]
+ public async Task UpdateRolePermissions_Should_AllowRootPermissions_When_CallerIsRootOperator()
+ {
+ // Sanity counterpart: the root operator CAN assign root-only permissions; the filter only
+ // restricts non-root callers.
+ using var rootClient = await _auth.CreateRootAdminClientAsync();
+ var unique = Guid.NewGuid().ToString("N")[..8];
+
+ var roleId = await CreateRoleAsync(rootClient, $"RootEscRole-{unique}");
+ await SetRolePermissionsAsync(rootClient, roleId, RootPermission);
+
+ var persisted = await GetRolePermissionsBodyAsync(rootClient, roleId);
+ persisted.Contains(RootPermission, StringComparison.Ordinal).ShouldBeTrue(
+ "the root operator may assign root-only permissions");
+ }
+
+ // ─── helpers ─────────────────────────────────────────────────────
+
+ private static async Task CreateRoleAsync(HttpClient client, string name)
+ {
+ var resp = await client.PostAsJsonAsync($"{TestConstants.IdentityBasePath}/roles",
+ new { id = string.Empty, name, description = "escalation test role" });
+ resp.IsSuccessStatusCode.ShouldBeTrue($"Create role failed: {await resp.Content.ReadAsStringAsync()}");
+ var role = await resp.DeserializeAsync();
+ return role.Id;
+ }
+
+ private static async Task SetRolePermissionsAsync(HttpClient client, string roleId, params string[] permissions)
+ {
+ var resp = await client.PutAsJsonAsync(
+ $"{TestConstants.IdentityBasePath}/{roleId}/permissions", new { roleId, permissions });
+ resp.StatusCode.ShouldBe(HttpStatusCode.OK, await resp.Content.ReadAsStringAsync());
+ }
+
+ private static async Task GetRolePermissionsBodyAsync(HttpClient client, string roleId)
+ {
+ var resp = await client.GetAsync($"{TestConstants.IdentityBasePath}/{roleId}/permissions");
+ resp.IsSuccessStatusCode.ShouldBeTrue($"Get role permissions failed: {await resp.Content.ReadAsStringAsync()}");
+ return await resp.Content.ReadAsStringAsync();
+ }
+
+ private async Task CreateTenantAdminClientWithRetryAsync(
+ string email, string password, string tenant, int maxRetries = 30)
+ {
+ for (var i = 0; i < maxRetries; i++)
+ {
+ try
+ {
+ return await _auth.CreateAuthenticatedClientAsync(email, password, tenant);
+ }
+ catch (HttpRequestException) when (i < maxRetries - 1)
+ {
+ await Task.Delay(1000);
+ }
+ }
+ return await _auth.CreateAuthenticatedClientAsync(email, password, tenant);
+ }
+
+ private static async Task CreateTenantAsync(HttpClient rootClient, string tenantId, string adminEmail)
+ {
+ var resp = await rootClient.PostAsJsonAsync(TestConstants.TenantsBasePath, new
+ {
+ id = tenantId,
+ name = $"Tenant {tenantId}",
+ connectionString = (string?)null,
+ adminEmail,
+ adminPassword = TestConstants.DefaultPassword,
+ issuer = $"{tenantId}.issuer"
+ });
+ 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;
+ public string Name { get; init; } = string.Empty;
+ }
+}