Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs
T
Oliver Kieselbach 445ce4e75b feat(backend): 30-day retention grace period on Pro downgrade
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>
2026-08-22 21:00:53 +02:00

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);
}
}