mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
445ce4e75b
Retention was the only enforcement point that destroyed state on a Pro -> Community downgrade: the 12-hourly sweep clamps to the Community cap at read time and would immediately start hard-deleting the 90-365-day band (trial expiry identical, silently). New policy: for 30 days after losing Pro (FeatureEntitlementCatalog.RetentionDowngradeGraceDays) the Pro cap keeps applying. Anchor model: ProDowngradedUtc (backend-only field, patch-denied + revert-protected) stamped/cleared by the plan endpoint's new pure ApplyPlanChanges core on EFFECTIVE-edition transitions (covers explicitly ended trials; a planTier downgrade under an active trial deliberately does not stamp); trial expiry anchors read-time via TrialExpiresUtc. days<=0 (infinite) stays untouched. Ops visibility: TenantPlanDowngraded (PATCH handler, new OpsEventService dep) plus TenantRetentionGraceExpiring/Ended from the daily TrialExpirySweep with the same stateless window mechanics, emitted only when stored retention exceeds the Community cap; all dual-registered in the web OPS_EVENT_TYPES catalog. Fanout now uses its _utcNow seam instead of inline DateTime.UtcNow. OKF doc backend/plan-downgrade-retention-grace.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
216 lines
8.3 KiB
C#
216 lines
8.3 KiB
C#
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
|
using AutopilotMonitor.Shared.Models;
|
|
using Azure.Data.Tables;
|
|
using Xunit;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests;
|
|
|
|
/// <summary>
|
|
/// Store↔Map roundtrip for the tenant-config table serialization, focused on the plan/trial
|
|
/// fields (project rule "table-serialization": every model field must appear in BOTH
|
|
/// ConvertToTenantTableEntity and ConvertFromTenantTableEntity). Includes legacy-row shapes
|
|
/// (rows written before the trial columns existed) which must map to safe defaults.
|
|
/// </summary>
|
|
public class TenantConfigTableSerializationTests
|
|
{
|
|
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
|
|
|
[Fact]
|
|
public void Roundtrip_PlanAndTrialFields_SurviveStoreAndMap()
|
|
{
|
|
var expires = new DateTime(2026, 8, 6, 10, 30, 0, DateTimeKind.Utc);
|
|
var started = new DateTime(2026, 7, 7, 10, 30, 0, DateTimeKind.Utc);
|
|
var downgraded = new DateTime(2026, 8, 10, 8, 0, 0, DateTimeKind.Utc);
|
|
var config = new TenantConfiguration
|
|
{
|
|
TenantId = TenantId,
|
|
DomainName = "contoso.com",
|
|
UpdatedBy = "admin@contoso.com",
|
|
PlanTier = "enterprise",
|
|
TrialExpiresUtc = expires,
|
|
TrialStartedUtc = started,
|
|
TrialConsumed = true,
|
|
TrialGrantedBy = "alice@contoso.com",
|
|
ProDowngradedUtc = downgraded
|
|
};
|
|
|
|
var entity = TableConfigRepository.ConvertToTenantTableEntity(config);
|
|
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(entity);
|
|
|
|
Assert.Equal("enterprise", mapped.PlanTier);
|
|
Assert.Equal(expires, mapped.TrialExpiresUtc);
|
|
Assert.Equal(started, mapped.TrialStartedUtc);
|
|
Assert.True(mapped.TrialConsumed);
|
|
Assert.Equal("alice@contoso.com", mapped.TrialGrantedBy);
|
|
Assert.Equal(downgraded, mapped.ProDowngradedUtc);
|
|
}
|
|
|
|
[Fact]
|
|
public void Roundtrip_NoTrial_NullsStayNull()
|
|
{
|
|
var config = new TenantConfiguration
|
|
{
|
|
TenantId = TenantId,
|
|
DomainName = "contoso.com",
|
|
UpdatedBy = "admin@contoso.com"
|
|
};
|
|
|
|
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
|
|
TableConfigRepository.ConvertToTenantTableEntity(config));
|
|
|
|
Assert.Null(mapped.TrialExpiresUtc);
|
|
Assert.Null(mapped.TrialStartedUtc);
|
|
Assert.False(mapped.TrialConsumed);
|
|
Assert.Null(mapped.TrialGrantedBy);
|
|
Assert.Null(mapped.ProDowngradedUtc);
|
|
}
|
|
|
|
[Fact]
|
|
public void Map_LegacyRow_WithoutTrialColumns_DefaultsSafely()
|
|
{
|
|
// A row written before the trial fields existed: no Trial* columns at all, legacy tier.
|
|
var entity = new TableEntity(TenantId, "config")
|
|
{
|
|
{ "DomainName", "fabrikam.com" },
|
|
{ "PlanTier", "pro" }
|
|
};
|
|
|
|
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(entity);
|
|
|
|
Assert.Equal("pro", mapped.PlanTier); // legacy value preserved (resolves to Community at read time)
|
|
Assert.Null(mapped.TrialExpiresUtc);
|
|
Assert.False(mapped.TrialConsumed);
|
|
Assert.Null(mapped.TrialGrantedBy);
|
|
Assert.Null(mapped.ProDowngradedUtc); // pre-grace row: no anchor, no grace
|
|
}
|
|
|
|
[Fact]
|
|
public void Map_LegacyRow_WithoutPlanTier_DefaultsToFree()
|
|
{
|
|
var entity = new TableEntity(TenantId, "config") { { "DomainName", "fabrikam.com" } };
|
|
Assert.Equal("free", TableConfigRepository.ConvertFromTenantTableEntity(entity).PlanTier);
|
|
}
|
|
|
|
[Fact]
|
|
public void Roundtrip_SessionGraceAndAbsoluteCapOverrides_SurviveStoreAndMap()
|
|
{
|
|
// These feed EnrollmentTimeoutClassifier via MaintenanceService — a tenant override that
|
|
// doesn't roundtrip means the sweep silently falls back to auto-derive/defaults.
|
|
var config = new TenantConfiguration
|
|
{
|
|
TenantId = TenantId,
|
|
DomainName = "contoso.com",
|
|
UpdatedBy = "admin@contoso.com",
|
|
SessionGraceHours = 72,
|
|
AbsoluteMaxSessionHours = 36
|
|
};
|
|
|
|
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
|
|
TableConfigRepository.ConvertToTenantTableEntity(config));
|
|
|
|
Assert.Equal(72, mapped.SessionGraceHours);
|
|
Assert.Equal(36, mapped.AbsoluteMaxSessionHours);
|
|
}
|
|
|
|
[Fact]
|
|
public void Map_LegacyRow_WithoutTimeoutColumns_DefaultsToAutoDerive()
|
|
{
|
|
// A row written before the reclassification columns existed: grace reads back as 0
|
|
// (auto-derive) and the absolute-cap override stays null (agent default 48h applies).
|
|
var entity = new TableEntity(TenantId, "config") { { "DomainName", "fabrikam.com" } };
|
|
|
|
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(entity);
|
|
|
|
Assert.Equal(0, mapped.SessionGraceHours);
|
|
Assert.Null(mapped.AbsoluteMaxSessionHours);
|
|
}
|
|
|
|
[Fact]
|
|
public void Roundtrip_EnableDoGroupIdAutoSet_SurvivesStoreAndMap()
|
|
{
|
|
var config = new TenantConfiguration
|
|
{
|
|
TenantId = TenantId,
|
|
DomainName = "contoso.com",
|
|
UpdatedBy = "admin@contoso.com",
|
|
EnableDoGroupIdAutoSet = true
|
|
};
|
|
|
|
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
|
|
TableConfigRepository.ConvertToTenantTableEntity(config));
|
|
|
|
Assert.True(mapped.EnableDoGroupIdAutoSet);
|
|
}
|
|
|
|
[Fact]
|
|
public void Map_LegacyRow_WithoutDoGroupIdColumn_ReadsNull()
|
|
{
|
|
// null = "not set" → GetAgentConfigFunction serves the agent default (false).
|
|
var entity = new TableEntity(TenantId, "config") { { "DomainName", "fabrikam.com" } };
|
|
Assert.Null(TableConfigRepository.ConvertFromTenantTableEntity(entity).EnableDoGroupIdAutoSet);
|
|
}
|
|
|
|
[Fact]
|
|
public void Roundtrip_NotificationChannelsJson_SurvivesStoreAndMap()
|
|
{
|
|
var channelsJson = "[{\"id\":\"ch-1\",\"name\":\"Service Desk\",\"providerType\":20,\"url\":\"https://desk.example/hook\",\"enabled\":true,\"notifyOnFailure\":true}]";
|
|
var config = new TenantConfiguration
|
|
{
|
|
TenantId = TenantId,
|
|
DomainName = "contoso.com",
|
|
UpdatedBy = "admin@contoso.com",
|
|
NotificationChannelsJson = channelsJson
|
|
};
|
|
|
|
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
|
|
TableConfigRepository.ConvertToTenantTableEntity(config));
|
|
|
|
Assert.Equal(channelsJson, mapped.NotificationChannelsJson);
|
|
}
|
|
|
|
[Fact]
|
|
public void Map_LegacyRow_WithoutChannelsColumn_ReadsNull()
|
|
{
|
|
// Pre-channels row: the column is absent — GetNotificationChannels() then synthesizes
|
|
// from the legacy single-webhook fields.
|
|
var entity = new TableEntity(TenantId, "config") { { "DomainName", "fabrikam.com" } };
|
|
Assert.Null(TableConfigRepository.ConvertFromTenantTableEntity(entity).NotificationChannelsJson);
|
|
}
|
|
|
|
[Fact]
|
|
public void Roundtrip_AppRegHomingFields_SurviveStoreAndMap()
|
|
{
|
|
var since = new DateTime(2026, 7, 31, 9, 0, 0, DateTimeKind.Utc);
|
|
var config = new TenantConfiguration
|
|
{
|
|
TenantId = TenantId,
|
|
DomainName = "contoso.com",
|
|
UpdatedBy = "admin@contoso.com",
|
|
HomedAppClientId = "886ab5e2-6144-442c-80cc-9b28e0667731",
|
|
LastAuthClientId = "886ab5e2-6144-442c-80cc-9b28e0667731",
|
|
LastAuthClientIdSince = since
|
|
};
|
|
|
|
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
|
|
TableConfigRepository.ConvertToTenantTableEntity(config));
|
|
|
|
Assert.Equal("886ab5e2-6144-442c-80cc-9b28e0667731", mapped.HomedAppClientId);
|
|
Assert.Equal("886ab5e2-6144-442c-80cc-9b28e0667731", mapped.LastAuthClientId);
|
|
Assert.Equal(since, mapped.LastAuthClientIdSince);
|
|
}
|
|
|
|
[Fact]
|
|
public void Map_LegacyRow_WithoutAppRegHomingColumns_ReadsNull()
|
|
{
|
|
// Every tenant row pre-dating the C4A8 app-reg move lacks these columns; null
|
|
// HomedAppClientId is the invariant for "homed on the legacy app registration".
|
|
var entity = new TableEntity(TenantId, "config") { { "DomainName", "fabrikam.com" } };
|
|
|
|
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(entity);
|
|
|
|
Assert.Null(mapped.HomedAppClientId);
|
|
Assert.Null(mapped.LastAuthClientId);
|
|
Assert.Null(mapped.LastAuthClientIdSince);
|
|
}
|
|
}
|