Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs
T
Oliver Kieselbach 12a75ef13d feat(delegation): MCP quota follows the managed tenant, delegated tenant slots, self-service delegation
Phase A — the budget follows the data
- A delegated (MSP) read is charged to the MANAGED tenant's organization MCP budget and checked against
  its plan; the caller's own budget still follows the home tenant. 429 names the managed tenant (domain)
  and the Pro upgrade path; new McpQuotaExceededResponse.targetTenantId.
- Bounded fleet aggregate (global/sessions, global/stats/sessions): exhausted managed tenants are excluded
  softly — RequestContext.AllowedTenantIds is narrowed (never widened, never null; pinned) and echoed as
  quotaExcludedTenants. Global Admins stay exempt and are tracked on their home tenant only.
- Tenant Group flag ChargeHomeTenantQuota (PATCH global/tenant-groups/{id}, audited per tenant): reads
  through the group are charged to the assignee's home tenant (operator-managed service groups).
- McpTenantUsage rows carry UserPrincipalName / HomeTenantId / LastRequestAt; new
  GET metrics/mcp-usage/organization (budget by account incl. delegated readers) rendered on MCP Usage.
- MCP: get_fleet_overview (delegated + platform) over the two subset-tier routes; error handler names
  the managed tenant. Other delegated tools stay tenant-mandatory.

Phase B — delegated tenant slots
- EditionEntitlements.MaxDelegatedTenants (Community 0 / Pro 2), TenantConfiguration.MaxDelegatedTenantsOverride
  (GA-only via PATCH config/{tenantId}/plan { maxDelegatedTenants }), DelegatedSlotService counting distinct
  managed tenants per home tenant through identity bindings (+ pending invitations + 24 h holds), enforced
  fresh on grant / group assign / add-tenant with 409 DelegatedSlotLimitReached; GA UI offers
  "raise to N & retry"; GET global/delegated-slots/{tenantId}; feature-flags entitlements.maxDelegatedTenants.

Phase C — self-service delegation
- One implicit Tenant Group msp-{tenantId} per managing tenant (OwnerTenantId on the meta row);
  DelegationInvitations table (invitations + slot release holds); DelegationInviteTicket (HMAC, 7 days,
  locates the row only — the accepting tenant is always the JWT tenant; one-shot via ETag).
- DelegationSelfService: ordered accept chain (distinct codes), remove/revoke with 24-hour slot hold, dual
  audit and live-stream disconnect, assignees bound to tenant membership, "who manages my tenant" view;
  GA release-hold escape hatch. No delegations/* route carries {tenantId} (pinned).
- Web: Settings → Tenant → Delegated Access, /delegations/accept?token=, AuthGate keeps the deep link
  across the activation detour, Tenant Management holds + release, owned groups read-only in GA UI.
- Offboarding wipes both sides (documented two-bucket membership); 90-day sweep of terminal rows.
- Trust pages (terms, privacy) and generated wire types updated; docs submodule bumped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 21:37:36 +02:00

317 lines
14 KiB
C#

using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Config;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for <see cref="GetTenantFeatureFlagsFunction.BuildPayload"/> projection.
///
/// CORRECTNESS GUARD: <c>/api/config/{tenantId}/feature-flags</c> is gated MemberRead, so every
/// field in this payload is implicitly readable by Operators and Viewers. A regression that
/// adds an admin-sensitive field here (webhook URLs, SAS tokens, admin lists, …) would expose
/// it to every tenant member. These tests lock the field set + nullable-default semantics.
/// </summary>
public class GetTenantFeatureFlagsPayloadTests
{
private static readonly DateTime Now = new(2026, 7, 7, 12, 0, 0, DateTimeKind.Utc);
private static JsonElement Serialize(TenantConfiguration config)
{
var payload = GetTenantFeatureFlagsFunction.BuildPayload(config, Now);
var json = TestWire.Serialize(payload);
return JsonDocument.Parse(json).RootElement;
}
[Fact]
public void Payload_HasExpectedFieldSet_AndNothingElse()
{
var element = Serialize(new TenantConfiguration());
var fieldNames = element.EnumerateObject().Select(p => p.Name).OrderBy(n => n).ToArray();
// CORRECTNESS GUARD: adding a new field to BuildPayload requires updating this test
// — and that forces a deliberate code review of whether the new field is non-sensitive.
Assert.Equal(new[]
{
// Non-sensitive by review: bool only, exposes no client ids (dual app-reg
// self-service migration banner).
"appHomingFunnelActive",
"bootstrapTokenEnabled",
// Non-sensitive by review: bool only ("is a company name set"), never the value
// itself (admin-gated full config only).
"companyNameSet",
// Non-sensitive by review: bool only ("is a contact address set"), never the
// address itself (admin-gated full config only).
"contactEmailSet",
"diagnosticsUploadConfigured",
"edition",
"enableIntegrityBypassAnalyzer",
"enableSoftwareInventoryAnalyzer",
"entitlements",
"isTrial",
"showScriptOutput",
"trialAvailable",
// trialExpiresUtc is absent here: the default tenant is not on a trial and the
// wire options omit null keys (WhenWritingNull) — exactly what production always
// served. Payload_ActiveTrial_IsTrialWithExpiry pins the key on an active trial.
"unrestrictedMode",
"validateAutopilotDevice",
}, fieldNames);
}
[Fact]
public void Payload_PassesThroughExplicitFieldValues()
{
var config = new TenantConfiguration
{
PlanTier = "pro", // unrestrictedMode is EFFECTIVE — requires Pro + gate + toggle
BootstrapTokenEnabled = true,
ValidateAutopilotDevice = true,
ShowScriptOutput = false,
EnableSoftwareInventoryAnalyzer = true,
EnableIntegrityBypassAnalyzer = false,
UnrestrictedModeEnabled = true,
UnrestrictedMode = true,
};
var element = Serialize(config);
Assert.True(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
Assert.True(element.GetProperty("validateAutopilotDevice").GetBoolean());
Assert.False(element.GetProperty("showScriptOutput").GetBoolean());
Assert.True(element.GetProperty("enableSoftwareInventoryAnalyzer").GetBoolean());
Assert.False(element.GetProperty("enableIntegrityBypassAnalyzer").GetBoolean());
Assert.True(element.GetProperty("unrestrictedMode").GetBoolean());
}
// ── Effective feature gates (plan-derived) ──────────────────────────────
[Fact]
public void Payload_Bootstrap_IncludedInPro_WithoutGaFlag()
{
var element = Serialize(new TenantConfiguration { PlanTier = "pro", BootstrapTokenEnabled = false });
Assert.True(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
}
[Fact]
public void Payload_Bootstrap_GaFlagStillWorksForCommunity()
{
var element = Serialize(new TenantConfiguration { PlanTier = "free", BootstrapTokenEnabled = true });
Assert.True(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
}
[Fact]
public void Payload_Bootstrap_CommunityWithoutFlag_Disabled()
{
var element = Serialize(new TenantConfiguration { PlanTier = "free", BootstrapTokenEnabled = false });
Assert.False(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
}
[Fact]
public void Payload_UnrestrictedMode_CommunityTenant_ForcedOff_DespiteBothFlags()
{
// Fail-closed re-gate: a downgraded/expired tenant keeps its stored flags but the
// effective value must be false.
var element = Serialize(new TenantConfiguration
{
PlanTier = "free",
UnrestrictedModeEnabled = true,
UnrestrictedMode = true,
});
Assert.False(element.GetProperty("unrestrictedMode").GetBoolean());
}
[Fact]
public void Payload_UnrestrictedMode_ProWithoutGaGate_Off()
{
var element = Serialize(new TenantConfiguration
{
PlanTier = "pro",
UnrestrictedModeEnabled = false,
UnrestrictedMode = true,
});
Assert.False(element.GetProperty("unrestrictedMode").GetBoolean());
}
[Fact]
public void Payload_NullableFlags_FallBackToAgentSideDefaults()
{
// ShowScriptOutput / EnableSoftwareInventoryAnalyzer / EnableIntegrityBypassAnalyzer are
// nullable on the model — null means "use the agent-side default". The endpoint flattens
// those nulls so the UI does not have to re-derive defaults.
var config = new TenantConfiguration
{
ShowScriptOutput = null,
EnableSoftwareInventoryAnalyzer = null,
EnableIntegrityBypassAnalyzer = null,
};
var element = Serialize(config);
Assert.True(element.GetProperty("showScriptOutput").GetBoolean(), "ShowScriptOutput default is true");
Assert.False(element.GetProperty("enableSoftwareInventoryAnalyzer").GetBoolean(), "SoftwareInventory default is false");
Assert.True(element.GetProperty("enableIntegrityBypassAnalyzer").GetBoolean(), "IntegrityBypass default is true");
}
[Fact]
public void Payload_DoesNotExposeAdminSensitiveFields()
{
// Sentinel: load up the model with values for known-admin-only fields and assert none
// of those fields leak into the payload. If a future field name lands in the model that
// happens to collide with an admin-only one here, this test will fail and force review.
var config = new TenantConfiguration
{
DiagnosticsBlobSasUrl = "https://example.blob.core.windows.net/diagnostics?sv=secret-sas-token",
TeamsWebhookUrl = "https://outlook.office.com/webhook/secret-team-hook",
WebhookUrl = "https://hooks.example.com/services/secret-generic-hook",
};
var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now));
Assert.DoesNotContain("secret-sas-token", json);
Assert.DoesNotContain("secret-team-hook", json);
Assert.DoesNotContain("secret-generic-hook", json);
Assert.DoesNotContain("diagnosticsBlobSasUrl", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("teamsWebhookUrl", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("webhookUrl", json, StringComparison.OrdinalIgnoreCase);
}
// ── diagnosticsUploadConfigured (drives the session-detail "Collect Logs" button) ──
[Theory]
// mode Off (or unset) → never configured, regardless of destination
[InlineData(null, null, null, false)]
[InlineData("Off", "https://x.blob.core.windows.net/c?sv=1", "CustomerSas", false)]
[InlineData("Off", null, "Hosted", false)]
// mode active + Hosted destination → configured without any SAS
[InlineData("OnFailure", null, "Hosted", true)]
[InlineData("Always", null, "Hosted", true)]
// mode active + CustomerSas → configured only when a SAS URL is present
[InlineData("OnFailure", "https://x.blob.core.windows.net/c?sv=1", "CustomerSas", true)]
[InlineData("OnFailure", null, "CustomerSas", false)]
[InlineData("Always", "", null, false)]
public void Payload_DiagnosticsUploadConfigured_ModeAndDestinationMatrix(
string? mode, string? sasUrl, string? destination, bool expected)
{
// Null-forgiving: the model declares these non-nullable with defaults, but table
// round-trips can produce null at runtime — the payload must tolerate that shape.
var element = Serialize(new TenantConfiguration
{
DiagnosticsUploadMode = mode!,
DiagnosticsBlobSasUrl = sasUrl!,
DiagnosticsUploadDestination = destination!,
});
Assert.Equal(expected, element.GetProperty("diagnosticsUploadConfigured").GetBoolean());
}
// ── Edition / trial surface ─────────────────────────────────────────────
[Fact]
public void Payload_CommunityDefault_EditionAndTrialAvailability()
{
var element = Serialize(new TenantConfiguration()); // PlanTier default "free" → Community
Assert.Equal("community", element.GetProperty("edition").GetString());
Assert.False(element.GetProperty("isTrial").GetBoolean());
Assert.True(element.GetProperty("trialAvailable").GetBoolean());
Assert.Equal(90, element.GetProperty("entitlements").GetProperty("retentionCapDays").GetInt32());
Assert.False(element.GetProperty("entitlements").GetProperty("delegatedAdminAllowed").GetBoolean());
Assert.Equal("community", element.GetProperty("entitlements").GetProperty("mcpUsagePlan").GetString());
Assert.Equal(0, element.GetProperty("entitlements").GetProperty("maxDelegatedTenants").GetInt32());
}
[Fact]
public void Payload_Entitlements_MaxDelegatedTenants_ProTwo_OverrideWins()
{
Assert.Equal(2, Serialize(new TenantConfiguration { PlanTier = "pro" })
.GetProperty("entitlements").GetProperty("maxDelegatedTenants").GetInt32());
Assert.Equal(7, Serialize(new TenantConfiguration { PlanTier = "pro", MaxDelegatedTenantsOverride = 7 })
.GetProperty("entitlements").GetProperty("maxDelegatedTenants").GetInt32());
}
[Theory]
[InlineData("pro")]
[InlineData("enterprise")] // legacy stored value resolves — and reports — as "pro"
public void Payload_PermanentProTier_NotTrial(string storedTier)
{
var element = Serialize(new TenantConfiguration { PlanTier = storedTier });
Assert.Equal("pro", element.GetProperty("edition").GetString());
Assert.False(element.GetProperty("isTrial").GetBoolean());
Assert.False(element.GetProperty("trialAvailable").GetBoolean());
Assert.Equal(365, element.GetProperty("entitlements").GetProperty("retentionCapDays").GetInt32());
Assert.Equal(150, element.GetProperty("entitlements").GetProperty("userRateLimitPerMinute").GetInt32());
Assert.True(element.GetProperty("entitlements").GetProperty("delegatedAdminAllowed").GetBoolean());
}
[Fact]
public void Payload_ActiveTrial_IsTrialWithExpiry()
{
var expiry = Now.AddDays(10);
var element = Serialize(new TenantConfiguration { TrialExpiresUtc = expiry, TrialConsumed = true });
Assert.Equal("pro", element.GetProperty("edition").GetString());
Assert.True(element.GetProperty("isTrial").GetBoolean());
Assert.False(element.GetProperty("trialAvailable").GetBoolean());
Assert.Equal(expiry, element.GetProperty("trialExpiresUtc").GetDateTime());
}
[Fact]
public void Payload_ExpiredTrial_DegradesToCommunity_NoTrialAvailable()
{
var element = Serialize(new TenantConfiguration { TrialExpiresUtc = Now.AddDays(-1), TrialConsumed = true });
Assert.Equal("community", element.GetProperty("edition").GetString());
Assert.False(element.GetProperty("isTrial").GetBoolean());
Assert.False(element.GetProperty("trialAvailable").GetBoolean(), "consumed trial must not be offered again");
}
// ── contactEmailSet (drives the Pro-requires-contact trial gate + banner) ──
[Theory]
[InlineData(null, false)]
[InlineData("", false)]
[InlineData(" ", false)]
[InlineData("it-ops@contoso.com", true)]
public void Payload_ContactEmailSet_ReflectsWhetherAnAddressIsStored(string? stored, bool expected)
{
var element = Serialize(new TenantConfiguration { ContactEmail = stored });
Assert.Equal(expected, element.GetProperty("contactEmailSet").GetBoolean());
}
[Fact]
public void Payload_ContactEmailSet_NeverLeaksTheAddress()
{
var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(
new TenantConfiguration { ContactEmail = "secret-contact@contoso.com" }, Now));
Assert.DoesNotContain("secret-contact", json);
}
// ── companyNameSet (second half of the Pro contact-profile gate) ──
[Theory]
[InlineData(null, false)]
[InlineData("", false)]
[InlineData(" ", false)]
[InlineData("Contoso Ltd.", true)]
public void Payload_CompanyNameSet_ReflectsWhetherANameIsStored(string? stored, bool expected)
{
var element = Serialize(new TenantConfiguration { CompanyName = stored });
Assert.Equal(expected, element.GetProperty("companyNameSet").GetBoolean());
}
[Fact]
public void Payload_CompanyNameSet_NeverLeaksTheName()
{
var json = TestWire.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(
new TenantConfiguration { CompanyName = "Secret Holdings" }, Now));
Assert.DoesNotContain("Secret Holdings", json);
}
}