mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
f795fbcb4e
True parallel operation, no forced re-consent: existing tenants keep signing in via the legacy app (1a400946), new tenants land on the C4A8 app (886ab5e2). Model "default legacy, funnel primary, learn in the background": - Web: am_auth_app flag -> NEXT_PUBLIC_ENTRA_DEFAULT_APP -> primary; get-started CTA hands over with ?authapp=primary; auth/me homedApp updates the flag so an operator tenant flip takes effect on each user's NEXT login; AADSTS90094/65001 one-shot cross-app retry, AADSTS65004 -> failed screen with previous-app link; am_login_attempt pins redirect returns to the app that started them. - Backend: TenantConfiguration.HomedAppClientId (null = legacy, GA-only flip via PUT config) + LastAuthClientId/Since observability; EntraAppRegistry resolves the acting app per tenant (no-op until the legacy config pair is set); Graph client-credentials, admin-consent URLs and the surfaced client id follow the homed app (app-scoped cache keys); JwtBearer [Authorize] pipeline now shares the middleware audience builders (was single-audience - would 401 legacy tokens after the swap); middleware trusts EntraId:LegacyClientId implicitly. - well-known association file now lists both app ids (publisher domain); deploy-web passes NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID / _DEFAULT_APP; mcp-server.bicep default client id -> C4A8 app. Backend xUnit 3730/3730, vitest 625/625 (13 new), tsc/ESLint clean, next build ok. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
186 lines
7.2 KiB
C#
186 lines
7.2 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 config = new TenantConfiguration
|
|
{
|
|
TenantId = TenantId,
|
|
DomainName = "contoso.com",
|
|
UpdatedBy = "admin@contoso.com",
|
|
PlanTier = "enterprise",
|
|
TrialExpiresUtc = expires,
|
|
TrialStartedUtc = started,
|
|
TrialConsumed = true,
|
|
TrialGrantedBy = "alice@contoso.com"
|
|
};
|
|
|
|
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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[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_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);
|
|
}
|
|
}
|