Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs
T
Oliver Kieselbach f795fbcb4e feat(auth): dual app-reg parallel operation - legacy gktatooine app alongside new C4A8 app
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>
2026-07-31 18:12:25 +02:00

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