mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
12a75ef13d
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>
317 lines
14 KiB
C#
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);
|
|
}
|
|
}
|