mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
Self-service trial stays once per tenant across offboarding; plan writers decide on the fresh row
Offboarding deletes the tenant configuration, and the returning tenant's first login recreated it with TrialConsumed=false, so the one-time 30-day Pro trial could be chained by offboarding and re-onboarding. - The offboard click reads the configuration fail-loud and ORs TrialConsumed into the OffboardingByTenantPointer (sticky across rounds, same ETag CAS as OffboardCount). - The first-login write (HandleNewTenantDomainAsync, the one write that sets OnboardedBy) carries it onto the new row; an unreadable pointer means no write, retried next login. Re-onboarding itself is unchanged, auto-approve included. - TenantConfigurationService.UpdateAsync: fresh projected row with ETag, offboarding tombstone refused (403 TenantSuspended), mutation may decline, conditional replace with the ordinary pre-write backup, re-read and re-decide on a lost race (409 after 3). SetTenantPlanTier and StartTrial use it, so a warm instance's cached row is never saved (it could lift the tombstone during the drain barrier) and two concurrent trial starts cannot both pass. - Tests: TenantConfigUpdateTests (service + both endpoints), pointer stickiness and the offboard endpoint in TenantOffboardFunctionTests, first-login carry-over in AuthFunctionSideEffectTests; shared EndpointHarness. Bumps internal (D-288, D-289, plan, docs). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
Submodule internal updated: be43a5f8e4...c41ad52fb5
@@ -1,5 +1,9 @@
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Functions.Infrastructure;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Tests.Offboarding;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models.Offboarding;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -32,6 +36,7 @@ public class AuthFunctionSideEffectTests
|
||||
private readonly Mock<IMetricsRepository> _metricsRepoMock;
|
||||
private readonly Mock<AutopilotMonitor.Functions.Services.Activation.ITenantAutoApproveEnqueuer> _autoApproveEnqueuerMock;
|
||||
private readonly FakeSignalRNotificationService _signalR;
|
||||
private readonly FakeOffboardingAuditRepository _offboardingRepo = new();
|
||||
private readonly AuthFunction _sut;
|
||||
|
||||
public AuthFunctionSideEffectTests()
|
||||
@@ -108,7 +113,8 @@ public class AuthFunctionSideEffectTests
|
||||
new Mock<AdminIdentityResolver>(
|
||||
_metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of<ILogger<AdminIdentityResolver>>()) { CallBase = false }.Object,
|
||||
_signalR,
|
||||
adminConfigService.Object);
|
||||
adminConfigService.Object,
|
||||
_offboardingRepo);
|
||||
|
||||
// Default: all fire-and-forget calls succeed
|
||||
_tenantConfigMock
|
||||
@@ -237,6 +243,86 @@ public class AuthFunctionSideEffectTests
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Returning tenant (D-288): the offboarding deleted the configuration, the pointer
|
||||
// carries the consumed self-service trial into the first-login write.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void SeedOffboardingPointer(bool trialConsumed) =>
|
||||
_offboardingRepo.Pointers[TenantId] = (new OffboardingByTenantPointer
|
||||
{
|
||||
PartitionKey = Constants.OffboardingPartitionKeys.ByTenant,
|
||||
RowKey = TenantId,
|
||||
TenantId = TenantId,
|
||||
LatestStatus = "Completed",
|
||||
OffboardCount = 1,
|
||||
TrialConsumed = trialConsumed,
|
||||
}, "\"etag\"");
|
||||
|
||||
private static TenantConfiguration ReturningTenantDefault()
|
||||
{
|
||||
// What auth/me hands over once the offboarding deleted the row: an unpersisted default.
|
||||
var config = TenantConfiguration.CreateDefault(TenantId);
|
||||
Assert.False(config.TrialConsumed);
|
||||
return config;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleNewTenantDomain_ReturningTenantThatUsedTheTrial_CarriesItIntoTheOnboardingWrite()
|
||||
{
|
||||
SeedOffboardingPointer(trialConsumed: true);
|
||||
var config = ReturningTenantDefault();
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
Assert.True(config.TrialConsumed);
|
||||
Assert.Equal(Upn, config.OnboardedBy);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(
|
||||
It.Is<TenantConfiguration>(c => c.TrialConsumed && c.OnboardedBy == Upn),
|
||||
It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
// Re-onboarding itself stays unrestricted: same signup path, auto-approve included.
|
||||
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
|
||||
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
|
||||
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
// The verify criterion of the finding: the trial gate refuses the returning tenant.
|
||||
var deny = PlanManagementFunction.EvaluateTrialStart(config, DateTime.UtcNow);
|
||||
Assert.Equal(Constants.ApiErrorCodes.TrialAlreadyConsumed, deny?.Code);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)] // offboarded without ever using the trial
|
||||
[InlineData(null)] // never offboarded
|
||||
public async Task HandleNewTenantDomain_NoConsumedTrialOnRecord_LeavesTheTrialAvailable(bool? pointerTrialConsumed)
|
||||
{
|
||||
if (pointerTrialConsumed is { } consumed)
|
||||
SeedOffboardingPointer(consumed);
|
||||
var config = ReturningTenantDefault();
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
Assert.False(config.TrialConsumed);
|
||||
Assert.Equal("contoso.com", config.DomainName);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(config, It.IsAny<string?>(), It.IsAny<string?>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleNewTenantDomain_OffboardingLookupFails_WritesNothingAndRetriesOnTheNextLogin()
|
||||
{
|
||||
// An onboarded row without the carry-over would hand the trial back; no write at all instead.
|
||||
_offboardingRepo.ThrowOnPointerRead = new Azure.RequestFailedException(503, "Server busy");
|
||||
var config = ReturningTenantDefault();
|
||||
|
||||
await _sut.HandleNewTenantDomainAsync(config, TenantId, Upn);
|
||||
|
||||
Assert.True(string.IsNullOrEmpty(config.DomainName));
|
||||
Assert.Null(config.OnboardedBy);
|
||||
_tenantConfigMock.Verify(x => x.SaveConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
_telegramMock.Verify(x => x.SendNewTenantSignupAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
_autoApproveEnqueuerMock.Verify(x => x.EnqueueAsync(
|
||||
It.IsAny<AutopilotMonitor.Functions.Services.Activation.TenantAutoApproveEnvelope>(),
|
||||
It.IsAny<TimeSpan?>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// HandleAuthClientIdTrackingAsync — the write must mutate a cache-BYPASSING
|
||||
// read, never the (possibly stale) cached view. Regression for the prod
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Azure.Core.Serialization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal HTTP endpoint harness: a mocked <see cref="HttpRequestData"/> whose FunctionContext carries the
|
||||
/// <see cref="RequestContext"/> the policy middleware would have resolved, serialized with the production
|
||||
/// wire settings.
|
||||
/// </summary>
|
||||
internal static class EndpointHarness
|
||||
{
|
||||
public static (HttpRequestData Req, FunctionContext Context) Request(
|
||||
string tenantId, string upn = "admin@contoso.com", string? jsonBody = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
services.Configure<WorkerOptions>(o => o.Serializer = new JsonObjectSerializer(ApiJsonOptions.Create()));
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var items = new Dictionary<object, object>
|
||||
{
|
||||
["RequestContext"] = new RequestContext
|
||||
{
|
||||
TenantId = tenantId,
|
||||
TargetTenantId = tenantId,
|
||||
UserPrincipalName = upn,
|
||||
IsTenantAdmin = true,
|
||||
UserRole = Constants.TenantRoles.Admin,
|
||||
},
|
||||
};
|
||||
var context = new Mock<FunctionContext>();
|
||||
context.SetupGet(c => c.Items).Returns(items);
|
||||
context.SetupGet(c => c.InstanceServices).Returns(provider);
|
||||
|
||||
var req = new Mock<HttpRequestData>(context.Object);
|
||||
req.SetupGet(r => r.Headers).Returns(new HttpHeadersCollection());
|
||||
req.SetupGet(r => r.Body).Returns(new MemoryStream(Encoding.UTF8.GetBytes(jsonBody ?? "")));
|
||||
req.Setup(r => r.CreateResponse()).Returns(() => new FakeResponse(context.Object));
|
||||
return (req.Object, context.Object);
|
||||
}
|
||||
|
||||
/// <summary>The <c>code</c> of an error envelope; null when the body carries none.</summary>
|
||||
public static string? ErrorCode(HttpResponseData response)
|
||||
{
|
||||
response.Body.Position = 0;
|
||||
using var doc = JsonDocument.Parse(response.Body);
|
||||
return doc.RootElement.TryGetProperty("code", out var code) ? code.GetString() : null;
|
||||
}
|
||||
|
||||
private sealed class FakeResponse : HttpResponseData
|
||||
{
|
||||
public FakeResponse(FunctionContext context) : base(context) { }
|
||||
public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
|
||||
public override HttpHeadersCollection Headers { get; set; } = new();
|
||||
public override Stream Body { get; set; } = new MemoryStream();
|
||||
public override HttpCookies Cookies { get; } = new Mock<HttpCookies>().Object;
|
||||
}
|
||||
}
|
||||
+6
@@ -88,9 +88,14 @@ internal sealed class FakeOffboardingAuditRepository : IOffboardingAuditReposito
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>When set, every pointer read throws — a storage outage on the re-onboarding lookup.</summary>
|
||||
public Exception? ThrowOnPointerRead { get; set; }
|
||||
|
||||
public Task<(OffboardingByTenantPointer? Pointer, string? ETag)> TryGetByTenantPointerAsync(
|
||||
string normalizedTenantId, CancellationToken ct = default)
|
||||
{
|
||||
if (ThrowOnPointerRead is { } ex)
|
||||
throw ex;
|
||||
if (Pointers.TryGetValue(normalizedTenantId, out var entry))
|
||||
{
|
||||
return Task.FromResult<(OffboardingByTenantPointer?, string?)>((Clone(entry.Pointer), entry.ETag));
|
||||
@@ -152,5 +157,6 @@ internal sealed class FakeOffboardingAuditRepository : IOffboardingAuditReposito
|
||||
PartitionKey = p.PartitionKey, RowKey = p.RowKey, TenantId = p.TenantId,
|
||||
LatestHistoryRowKey = p.LatestHistoryRowKey, LatestStatus = p.LatestStatus,
|
||||
LatestUpdatedAt = p.LatestUpdatedAt, OffboardCount = p.OffboardCount,
|
||||
TrialConsumed = p.TrialConsumed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="TenantConfigurationService.UpdateAsync"/> and the plan writers built on it: the decision runs
|
||||
/// on the fresh row and the write is conditional on that row's ETag — a cached view (up to 5 minutes stale
|
||||
/// per instance) is never saved, so it can neither rewind another writer's change nor lift the offboarding
|
||||
/// tombstone during the drain barrier, and two concurrent trial starts cannot both see TrialConsumed=false.
|
||||
/// </summary>
|
||||
public class TenantConfigUpdateTests
|
||||
{
|
||||
private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
||||
|
||||
private readonly Mock<IConfigRepository> _repo = new();
|
||||
private readonly Queue<(TenantConfiguration Row, string ETag)> _freshReads = new();
|
||||
private readonly List<(TenantConfiguration Config, string ETag, string? Source, string? Reason)> _replaces = new();
|
||||
private readonly Queue<bool> _replaceResults = new();
|
||||
|
||||
public TenantConfigUpdateTests()
|
||||
{
|
||||
_repo.Setup(r => r.GetTenantConfigurationWithEtagAsync(TenantId))
|
||||
.ReturnsAsync(() => _freshReads.Count > 0 ? _freshReads.Dequeue() : null);
|
||||
_repo.Setup(r => r.TryReplaceTenantConfigurationAsync(
|
||||
It.IsAny<TenantConfiguration>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.Callback<TenantConfiguration, string, string?, string?>((c, e, s, r) => _replaces.Add((c, e, s, r)))
|
||||
.ReturnsAsync(() => _replaceResults.Count == 0 || _replaceResults.Dequeue());
|
||||
}
|
||||
|
||||
private TenantConfigurationService Service(ManagedTenantProIndex? proIndex = null) =>
|
||||
new(_repo.Object, NullLogger<TenantConfigurationService>.Instance,
|
||||
new MemoryCache(new MemoryCacheOptions()), proIndex ?? ManagedTenantProIndex.None);
|
||||
|
||||
private static TenantConfiguration Row(Action<TenantConfiguration>? shape = null)
|
||||
{
|
||||
var row = TenantConfiguration.CreateDefault(TenantId);
|
||||
row.DomainName = "contoso.com";
|
||||
row.ContactEmail = "it@contoso.com";
|
||||
row.CompanyName = "Contoso";
|
||||
shape?.Invoke(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static TenantConfiguration Tombstone() => Row(r =>
|
||||
{
|
||||
r.Disabled = true;
|
||||
r.DisabledReason = TenantOffboardFunction.OffboardingDisabledReason;
|
||||
});
|
||||
|
||||
/// <summary>Primes the service's cache with <paramref name="cached"/>, the view a warm instance would hold.</summary>
|
||||
private async Task PrimeCacheAsync(TenantConfigurationService service, TenantConfiguration cached)
|
||||
{
|
||||
_repo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync(cached);
|
||||
Assert.Same(cached, await service.GetConfigurationIfExistsAsync(TenantId));
|
||||
}
|
||||
|
||||
// ── UpdateAsync ─────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_MutatesTheFreshRow_UnderItsETag_NeverTheCachedView()
|
||||
{
|
||||
var service = Service();
|
||||
await PrimeCacheAsync(service, Row(r => r.HomedAppClientId = null));
|
||||
_freshReads.Enqueue((Row(r => r.HomedAppClientId = "flipped-meanwhile"), "etag-1"));
|
||||
|
||||
var update = await service.UpdateAsync(TenantId, row => { row.PayingCustomer = true; return true; }, "plan", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.Updated, update.Status);
|
||||
var (written, etag, source, reason) = Assert.Single(_replaces);
|
||||
Assert.Equal("etag-1", etag);
|
||||
Assert.Equal("flipped-meanwhile", written.HomedAppClientId);
|
||||
Assert.True(written.PayingCustomer);
|
||||
Assert.Equal("plan", source);
|
||||
Assert.Equal("why", reason);
|
||||
_repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
|
||||
// The cached view is gone: the next read goes to storage.
|
||||
await service.GetConfigurationIfExistsAsync(TenantId);
|
||||
_repo.Verify(r => r.GetTenantConfigurationAsync(TenantId), Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_LostRace_RereadsAndDecidesAgainOnTheNewRow()
|
||||
{
|
||||
var first = Row();
|
||||
var second = Row(r => r.CompanyName = "Contoso after the concurrent write");
|
||||
_freshReads.Enqueue((first, "etag-1"));
|
||||
_freshReads.Enqueue((second, "etag-2"));
|
||||
_replaceResults.Enqueue(false);
|
||||
_replaceResults.Enqueue(true);
|
||||
var seen = new List<TenantConfiguration>();
|
||||
|
||||
var update = await Service().UpdateAsync(TenantId, row => { seen.Add(row); return true; }, "plan", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.Updated, update.Status);
|
||||
Assert.Equal(new[] { first, second }, seen);
|
||||
Assert.Equal(new[] { "etag-1", "etag-2" }, _replaces.Select(r => r.ETag));
|
||||
Assert.Same(second, update.Config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_EveryAttemptLosesTheRace_Conflict()
|
||||
{
|
||||
for (var i = 0; i < TenantConfigurationService.MaxUpdateAttempts; i++)
|
||||
{
|
||||
_freshReads.Enqueue((Row(), $"etag-{i}"));
|
||||
_replaceResults.Enqueue(false);
|
||||
}
|
||||
|
||||
var update = await Service().UpdateAsync(TenantId, _ => true, "plan", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.Conflict, update.Status);
|
||||
Assert.Equal(TenantConfigurationService.MaxUpdateAttempts, _replaces.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_OffboardingTombstone_IsNeverWritten()
|
||||
{
|
||||
_freshReads.Enqueue((Tombstone(), "etag-1"));
|
||||
var mutated = false;
|
||||
|
||||
var update = await Service().UpdateAsync(TenantId, _ => mutated = true, "plan", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.OffboardingInProgress, update.Status);
|
||||
Assert.False(mutated);
|
||||
Assert.Empty(_replaces);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_MutationDeclines_NothingIsWritten()
|
||||
{
|
||||
var row = Row();
|
||||
_freshReads.Enqueue((row, "etag-1"));
|
||||
|
||||
var update = await Service().UpdateAsync(TenantId, _ => false, "plan", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.Declined, update.Status);
|
||||
Assert.Same(row, update.Config);
|
||||
Assert.Empty(_replaces);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_NoRow_NotFound()
|
||||
{
|
||||
var update = await Service().UpdateAsync(TenantId, _ => true, "plan", "why");
|
||||
|
||||
Assert.Equal(TenantConfigUpdateStatus.NotFound, update.Status);
|
||||
Assert.Empty(_replaces);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateAsync_TheMutationSeesTheConferredProProjection()
|
||||
{
|
||||
// EvaluateTrialStart's AlreadyPro verdict for a managed tenant depends on it.
|
||||
_freshReads.Enqueue((Row(), "etag-1"));
|
||||
string? seen = null;
|
||||
|
||||
await Service(new StubManagedTenantProIndex(_ => "managing-tenant"))
|
||||
.UpdateAsync(TenantId, row => { seen = row.ManagedByProTenantId; return false; }, "plan", "why");
|
||||
|
||||
Assert.Equal("managing-tenant", seen);
|
||||
}
|
||||
|
||||
// ── Endpoints ───────────────────────────────────────────────────────────
|
||||
|
||||
private (PlanManagementFunction Sut, Mock<IOpsEventRepository> OpsRepo, Mock<IMaintenanceRepository> Audit) Plan(
|
||||
TenantConfigurationService service)
|
||||
{
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
_repo.Object, NullLogger<AdminConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()))
|
||||
{ CallBase = false };
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
var audit = new Mock<IMaintenanceRepository>();
|
||||
var sut = new PlanManagementFunction(
|
||||
NullLogger<PlanManagementFunction>.Instance, service, adminConfig.Object, audit.Object,
|
||||
new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance,
|
||||
TestNotifications.InertOpsAlertDispatch(adminConfig.Object)),
|
||||
TestProConferral.Inert());
|
||||
return (sut, opsRepo, audit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartTrial_ConcurrentStartWonTheRace_SecondGets409TrialAlreadyConsumed()
|
||||
{
|
||||
_freshReads.Enqueue((Row(), "etag-1"));
|
||||
_freshReads.Enqueue((Row(r => r.TrialConsumed = true), "etag-2"));
|
||||
_replaceResults.Enqueue(false);
|
||||
var (sut, opsRepo, audit) = Plan(Service());
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await sut.StartTrial(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.TrialAlreadyConsumed, EndpointHarness.ErrorCode(response));
|
||||
Assert.Single(_replaces);
|
||||
opsRepo.Verify(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()), Times.Never);
|
||||
audit.Verify(a => a.LogAuditEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartTrial_WarmCacheDuringTheDrainBarrier_CannotLiftTheTombstone()
|
||||
{
|
||||
var service = Service();
|
||||
await PrimeCacheAsync(service, Row()); // this instance still sees the tenant enabled
|
||||
_freshReads.Enqueue((Tombstone(), "etag-1"));
|
||||
var (sut, _, _) = Plan(service);
|
||||
var (req, _) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await sut.StartTrial(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Equal(Constants.ApiErrorCodes.TenantSuspended, EndpointHarness.ErrorCode(response));
|
||||
Assert.Empty(_replaces);
|
||||
_repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartTrial_Allowed_WritesTheTrialOntoTheFreshRow()
|
||||
{
|
||||
var service = Service();
|
||||
await PrimeCacheAsync(service, Row(r => r.CompanyName = "stale"));
|
||||
_freshReads.Enqueue((Row(r => r.CompanyName = "Contoso"), "etag-1"));
|
||||
var (sut, opsRepo, _) = Plan(service);
|
||||
var (req, _) = EndpointHarness.Request(TenantId, upn: "admin@contoso.com");
|
||||
|
||||
var response = await sut.StartTrial(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var (written, etag, source, reason) = Assert.Single(_replaces);
|
||||
Assert.Equal("etag-1", etag);
|
||||
Assert.Equal("Contoso", written.CompanyName);
|
||||
Assert.True(written.TrialConsumed);
|
||||
Assert.Equal("admin@contoso.com", written.TrialGrantedBy);
|
||||
Assert.Equal(written.TrialStartedUtc!.Value.AddDays(PlanManagementFunction.SelfServiceTrialDays), written.TrialExpiresUtc);
|
||||
Assert.Equal("plan", source);
|
||||
Assert.Equal("self-service trial start", reason);
|
||||
opsRepo.Verify(r => r.SaveOpsEventAsync(It.Is<OpsEventEntry>(e => e.EventType == OpsEventTypes.TenantTrialStarted)), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetPlanTier_WritesOnTheFreshRowUnderItsETag()
|
||||
{
|
||||
var service = Service();
|
||||
await PrimeCacheAsync(service, Row(r => r.CompanyName = "stale"));
|
||||
_freshReads.Enqueue((Row(r => r.CompanyName = "Contoso"), "etag-1"));
|
||||
var (sut, _, audit) = Plan(service);
|
||||
var (req, _) = EndpointHarness.Request(TenantId,
|
||||
jsonBody: $"{{\"{PlanManagementFunction.PlanPatchKeys.PayingCustomer}\":true}}");
|
||||
|
||||
var response = await sut.SetPlanTier(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var (written, etag, _, _) = Assert.Single(_replaces);
|
||||
Assert.Equal("etag-1", etag);
|
||||
Assert.Equal("Contoso", written.CompanyName);
|
||||
Assert.True(written.PayingCustomer);
|
||||
audit.Verify(a => a.LogAuditEntryAsync(TenantId, "UPDATE", "TenantPlan", TenantId, It.IsAny<string>(),
|
||||
It.Is<Dictionary<string, string>>(d => d.ContainsKey("PayingCustomer"))), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetPlanTier_NothingChangesOnTheFreshRow_200WithoutAWrite()
|
||||
{
|
||||
_freshReads.Enqueue((Row(r => r.PayingCustomer = true), "etag-1"));
|
||||
var (sut, _, audit) = Plan(Service());
|
||||
var (req, _) = EndpointHarness.Request(TenantId,
|
||||
jsonBody: $"{{\"{PlanManagementFunction.PlanPatchKeys.PayingCustomer}\":true}}");
|
||||
|
||||
var response = await sut.SetPlanTier(req, TenantId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Empty(_replaces);
|
||||
audit.Verify(a => a.LogAuditEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>>()), Times.Never);
|
||||
}
|
||||
}
|
||||
@@ -196,7 +196,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
var sut = Build(repo);
|
||||
var historyRowKey = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}_{TenantId}";
|
||||
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, historyRowKey, DateTime.UtcNow);
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, historyRowKey, DateTime.UtcNow, trialConsumed: false);
|
||||
|
||||
Assert.True(repo.Pointers.TryGetValue(TenantId, out var entry));
|
||||
Assert.Equal(historyRowKey, entry.Pointer.LatestHistoryRowKey);
|
||||
@@ -222,13 +222,113 @@ public sealed class TenantOffboardFunctionTests
|
||||
var sut = Build(repo);
|
||||
var newHistoryRowKey = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}_{TenantId}";
|
||||
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, newHistoryRowKey, DateTime.UtcNow);
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, newHistoryRowKey, DateTime.UtcNow, trialConsumed: false);
|
||||
|
||||
Assert.Equal(newHistoryRowKey, repo.Pointers[TenantId].Pointer.LatestHistoryRowKey);
|
||||
Assert.Equal("Initiated", repo.Pointers[TenantId].Pointer.LatestStatus);
|
||||
Assert.Equal(3, repo.Pointers[TenantId].Pointer.OffboardCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, false, false)]
|
||||
[InlineData(false, true, true)]
|
||||
[InlineData(true, false, true)] // a later round without a trial never clears it
|
||||
[InlineData(true, true, true)]
|
||||
public async Task UpsertPointer_TrialConsumed_IsStickyAcrossRounds(bool stored, bool thisRound, bool expected)
|
||||
{
|
||||
var repo = new FakeOffboardingAuditRepository();
|
||||
await repo.InsertByTenantPointerAsync(new OffboardingByTenantPointer
|
||||
{
|
||||
PartitionKey = Constants.OffboardingPartitionKeys.ByTenant,
|
||||
RowKey = TenantId,
|
||||
TenantId = TenantId,
|
||||
LatestHistoryRowKey = "20260101000000000_" + TenantId,
|
||||
LatestStatus = "Completed",
|
||||
OffboardCount = 1,
|
||||
TrialConsumed = stored,
|
||||
});
|
||||
|
||||
await Build(repo).UpsertPointerWithCasAsync(TenantId, "20260201000000000_" + TenantId, DateTime.UtcNow, thisRound);
|
||||
|
||||
Assert.Equal(expected, repo.Pointers[TenantId].Pointer.TrialConsumed);
|
||||
Assert.Equal(2, repo.Pointers[TenantId].Pointer.OffboardCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task UpsertPointer_FreshTenant_StampsTrialConsumed(bool trialConsumed)
|
||||
{
|
||||
var repo = new FakeOffboardingAuditRepository();
|
||||
|
||||
await Build(repo).UpsertPointerWithCasAsync(TenantId, "20260201000000000_" + TenantId, DateTime.UtcNow, trialConsumed);
|
||||
|
||||
Assert.Equal(trialConsumed, repo.Pointers[TenantId].Pointer.TrialConsumed);
|
||||
}
|
||||
|
||||
// ── OffboardTenant (Phase 1): the consumed trial must reach the pointer before anything is deleted ──
|
||||
|
||||
private static (TenantOffboardFunction Sut, FakeOffboardingAuditRepository Repo, RecordingEnqueuer Enqueuer, Mock<IConfigRepository> ConfigRepo)
|
||||
BuildForOffboard(TenantConfiguration? liveConfig, Exception? readFailure = null)
|
||||
{
|
||||
var configRepo = new Mock<IConfigRepository>();
|
||||
if (readFailure != null)
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>())).ThrowsAsync(readFailure);
|
||||
else
|
||||
configRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>())).ReturnsAsync(liveConfig);
|
||||
configRepo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var repo = new FakeOffboardingAuditRepository();
|
||||
var enqueuer = new RecordingEnqueuer();
|
||||
var sut = new TenantOffboardFunction(
|
||||
logger: NullLogger<TenantOffboardFunction>.Instance,
|
||||
configRepo: configRepo.Object,
|
||||
tenantConfigService: new TenantConfigurationService(
|
||||
configRepo.Object, NullLogger<TenantConfigurationService>.Instance,
|
||||
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions())),
|
||||
maintenanceRepo: Mock.Of<IMaintenanceRepository>(),
|
||||
offboardingRepo: repo,
|
||||
offboardingEnqueuer: enqueuer,
|
||||
previewWhitelistService: BuildPreviewWhitelistService(configRepo));
|
||||
return (sut, repo, enqueuer, configRepo);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(true)]
|
||||
[InlineData(false)]
|
||||
public async Task OffboardTenant_CarriesTheLiveTrialConsumedOntoThePointer(bool trialConsumed)
|
||||
{
|
||||
var live = TenantConfiguration.CreateDefault(TenantId);
|
||||
live.DomainName = "contoso.com";
|
||||
live.TrialConsumed = trialConsumed;
|
||||
var (sut, repo, enqueuer, _) = BuildForOffboard(live);
|
||||
var (req, context) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await sut.OffboardTenant(req, TenantId, context);
|
||||
|
||||
Assert.Equal(System.Net.HttpStatusCode.Accepted, response.StatusCode);
|
||||
Assert.Equal(trialConsumed, repo.Pointers[TenantId].Pointer.TrialConsumed);
|
||||
Assert.Single(enqueuer.Sent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OffboardTenant_ConfigReadFails_RefusesBeforeAnyStateIsWritten()
|
||||
{
|
||||
// Proceeding without the read would reset the one-trial rule for the returning tenant.
|
||||
var (sut, repo, enqueuer, configRepo) = BuildForOffboard(null, new RequestFailedException(503, "Server busy"));
|
||||
var (req, context) = EndpointHarness.Request(TenantId);
|
||||
|
||||
var response = await sut.OffboardTenant(req, TenantId, context);
|
||||
|
||||
Assert.Equal(System.Net.HttpStatusCode.InternalServerError, response.StatusCode);
|
||||
Assert.Empty(repo.History);
|
||||
Assert.Empty(repo.Pointers);
|
||||
Assert.Empty(repo.Markers);
|
||||
Assert.Empty(enqueuer.Sent);
|
||||
configRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertPointer_409OnInsert_FallsBackToEtagUpdate()
|
||||
{
|
||||
@@ -273,7 +373,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
var sut = Build(repoMock.Object);
|
||||
var newHistoryRowKey = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}_{TenantId}";
|
||||
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, newHistoryRowKey, DateTime.UtcNow);
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, newHistoryRowKey, DateTime.UtcNow, trialConsumed: false);
|
||||
|
||||
Assert.NotNull(updatedPointer);
|
||||
Assert.Equal(newHistoryRowKey, updatedPointer!.LatestHistoryRowKey);
|
||||
@@ -325,7 +425,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
var sut = Build(repoMock.Object);
|
||||
var newHistoryRowKey = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}_{TenantId}";
|
||||
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, newHistoryRowKey, DateTime.UtcNow);
|
||||
await sut.UpsertPointerWithCasAsync(TenantId, newHistoryRowKey, DateTime.UtcNow, trialConsumed: false);
|
||||
|
||||
Assert.Equal(2, updateCalls);
|
||||
Assert.Equal(2, readIdx);
|
||||
@@ -484,7 +584,7 @@ public sealed class TenantOffboardFunctionTests
|
||||
var historyRowKey = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}_{TenantId}";
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
sut.UpsertPointerWithCasAsync(TenantId, historyRowKey, DateTime.UtcNow));
|
||||
sut.UpsertPointerWithCasAsync(TenantId, historyRowKey, DateTime.UtcNow, trialConsumed: false));
|
||||
}
|
||||
|
||||
// ── Cache-Drain-Barrier (plan v2 §2.2 / §2.3) ─────────────────────────────
|
||||
|
||||
+11
@@ -134,6 +134,17 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> TryReplaceTenantConfigurationAsync(
|
||||
TenantConfiguration config, string etag, string? backupSource, string? backupReason)
|
||||
{
|
||||
await TrySnapshotBeforeSaveAsync(
|
||||
_tenantConfigTableClient, config.TenantId, "config",
|
||||
ConvertFromTenantTableEntity, config, TenantBackupNoiseProperties,
|
||||
config.UpdatedBy, backupSource, backupReason);
|
||||
|
||||
return await TryReplaceTenantConfigurationAsync(config, etag);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-write snapshot hook shared by the tenant- and admin-config save paths.
|
||||
/// Fail-SOFT by design: this is a safety net around long-standing writers
|
||||
|
||||
+3
@@ -282,6 +282,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
["LatestStatus"] = p.LatestStatus,
|
||||
["LatestUpdatedAt"] = p.LatestUpdatedAt,
|
||||
["OffboardCount"] = p.OffboardCount,
|
||||
["TrialConsumed"] = p.TrialConsumed,
|
||||
};
|
||||
|
||||
private static OffboardingByTenantPointer MapByTenantPointer(TableEntity e) => new()
|
||||
@@ -293,6 +294,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
LatestStatus = e.GetString("LatestStatus") ?? "Initiated",
|
||||
LatestUpdatedAt = e.GetDateTime("LatestUpdatedAt") ?? default,
|
||||
OffboardCount = e.GetInt32("OffboardCount") ?? 1,
|
||||
// Absent on pointers from before the column; false is correct for all of them (D-288).
|
||||
TrialConsumed = e.GetBoolean("TrialConsumed") ?? false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,19 +100,22 @@ public class TenantOffboardFunction
|
||||
"TENANT OFFBOARD initiated for tenant {TenantId} by {Upn}",
|
||||
normalizedTenantId, upn);
|
||||
|
||||
// 2. Capture DomainName from TenantConfiguration so the worker's Completed/Failed
|
||||
// OpsEvents can render "{domain} ({tenantId})" instead of the bare GUID.
|
||||
string? domainName = null;
|
||||
// 2. Capture what must outlive the TenantConfiguration row the cascade deletes: DomainName
|
||||
// for the worker's Completed/Failed OpsEvents ("{domain} ({tenantId})"), and TrialConsumed
|
||||
// for the pointer, which carries it to the returning tenant's first login (D-288).
|
||||
// Fail-loud: without the read the one-trial-per-tenant rule would silently reset.
|
||||
string? domainName;
|
||||
bool trialConsumed;
|
||||
try
|
||||
{
|
||||
var existingConfig = await _configRepo.GetTenantConfigurationAsync(targetTenantId);
|
||||
domainName = existingConfig?.DomainName;
|
||||
trialConsumed = existingConfig?.TrialConsumed ?? false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Failed to read tenant configuration for {TenantId} before offboard; proceeding without domain name",
|
||||
normalizedTenantId);
|
||||
_logger.LogError(ex, "Failed to read tenant configuration for {TenantId} before offboard", normalizedTenantId);
|
||||
return await Build500Async(req, "Failed to read tenant configuration");
|
||||
}
|
||||
|
||||
// 2.b — Capture the Preview-Notification-Email before Phase 2.D wipes the
|
||||
@@ -184,14 +187,14 @@ public class TenantOffboardFunction
|
||||
// 5. Upsert ByTenant pointer with ETag-CAS so OffboardCount increments race-safely.
|
||||
try
|
||||
{
|
||||
await UpsertPointerWithCasAsync(normalizedTenantId, historyRowKey, now);
|
||||
await UpsertPointerWithCasAsync(normalizedTenantId, historyRowKey, now, trialConsumed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// History row is already committed — operator can find it under
|
||||
// PartitionKey="OffboardingHistory" and retry manually. Graceful degradation
|
||||
// (plan §4.4): pointer is the re-onboarding index; without it PR4's auto-wipe
|
||||
// is no-op but the offboarding itself still proceeds.
|
||||
// PartitionKey="OffboardingHistory". Nothing destructive has started: without the
|
||||
// pointer the returning tenant would get its trial back, so refuse and let the
|
||||
// admin re-click.
|
||||
_logger.LogError(ex,
|
||||
"Failed to upsert OffboardingByTenant pointer for {TenantId} after history row inserted",
|
||||
normalizedTenantId);
|
||||
@@ -869,11 +872,10 @@ public class TenantOffboardFunction
|
||||
}
|
||||
|
||||
// Plan §4.4 read-modify-write CAS-loop. Bounded retry; throws on exhaustion so the
|
||||
// caller turns it into a 500. The pointer is the O(1) re-onboarding index — failure
|
||||
// here is recoverable (graceful degradation in PR4) but we still surface it so the
|
||||
// operator can investigate.
|
||||
// caller turns it into a 500. The pointer is the O(1) re-onboarding index; TrialConsumed
|
||||
// is sticky across rounds (OR), never reset by an offboarding without a trial.
|
||||
internal async Task UpsertPointerWithCasAsync(
|
||||
string normalizedTenantId, string historyRowKey, DateTime now)
|
||||
string normalizedTenantId, string historyRowKey, DateTime now, bool trialConsumed)
|
||||
{
|
||||
for (var attempt = 1; attempt <= PointerCasMaxAttempts; attempt++)
|
||||
{
|
||||
@@ -890,6 +892,7 @@ public class TenantOffboardFunction
|
||||
LatestStatus = "Initiated",
|
||||
LatestUpdatedAt = now,
|
||||
OffboardCount = 1,
|
||||
TrialConsumed = trialConsumed,
|
||||
};
|
||||
|
||||
try
|
||||
@@ -910,6 +913,7 @@ public class TenantOffboardFunction
|
||||
existing.LatestStatus = "Initiated";
|
||||
existing.LatestUpdatedAt = now;
|
||||
existing.OffboardCount = existing.OffboardCount + 1;
|
||||
existing.TrialConsumed |= trialConsumed;
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
@@ -198,33 +199,36 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
}
|
||||
}
|
||||
|
||||
var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
|
||||
if (config == null)
|
||||
{
|
||||
return await req.NotFoundAsync("Tenant not found");
|
||||
}
|
||||
|
||||
var nowUtc = _time.GetUtcNow().UtcDateTime;
|
||||
var changes = new Dictionary<string, string>();
|
||||
var wasPermanentPro = false;
|
||||
TenantEdition editionBefore = default, editionAfter = default;
|
||||
|
||||
// Conferred Pro follows the PERMANENT tier of the managing tenant (a trial confers nothing):
|
||||
// remember the standing before the mutation so a permanent-Pro → non-permanent drop can
|
||||
// stamp the managed tenants' grace anchors below.
|
||||
var wasPermanentPro = FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier);
|
||||
var (editionBefore, editionAfter) =
|
||||
ApplyPlanChanges(config, newPlanTier, trialProvided, newTrialExpiresUtc, caller, nowUtc, changes);
|
||||
ApplyDelegatedSlotChange(config, slotsProvided, newMaxDelegatedTenants, changes);
|
||||
ApplyMcpUsagePlanChange(config, mcpPlanProvided, newMcpUsagePlan, changes);
|
||||
ApplyPayingCustomerChange(config, payingProvided, newPayingCustomer, changes);
|
||||
|
||||
if (changes.Count > 0)
|
||||
// Decided on the fresh row and written under its ETag; the mutation re-runs on a lost race.
|
||||
var update = await _configService.UpdateAsync(requestCtx.TargetTenantId, row =>
|
||||
{
|
||||
config.UpdatedBy = caller;
|
||||
// Fail-loud: SaveConfigurationAsync throws when the write did not persist (and
|
||||
// invalidates the cached — possibly mutated — instance in its finally), so a
|
||||
// failed save can never be audited or returned as 200.
|
||||
await _configService.SaveConfigurationAsync(config, "plan", "plan tier change");
|
||||
changes.Clear();
|
||||
// Conferred Pro follows the PERMANENT tier of the managing tenant (a trial confers nothing):
|
||||
// remember the standing before the mutation so a permanent-Pro → non-permanent drop can
|
||||
// stamp the managed tenants' grace anchors below.
|
||||
wasPermanentPro = FeatureEntitlementCatalog.IsPermanentProTier(row.PlanTier);
|
||||
(editionBefore, editionAfter) =
|
||||
ApplyPlanChanges(row, newPlanTier, trialProvided, newTrialExpiresUtc, caller, nowUtc, changes);
|
||||
ApplyDelegatedSlotChange(row, slotsProvided, newMaxDelegatedTenants, changes);
|
||||
ApplyMcpUsagePlanChange(row, mcpPlanProvided, newMcpUsagePlan, changes);
|
||||
ApplyPayingCustomerChange(row, payingProvided, newPayingCustomer, changes);
|
||||
if (changes.Count == 0)
|
||||
return false;
|
||||
row.UpdatedBy = caller;
|
||||
return true;
|
||||
}, "plan", "plan tier change");
|
||||
|
||||
if (await UpdateFailureAsync(req, update) is { } failure)
|
||||
return failure;
|
||||
var config = update.Config!;
|
||||
|
||||
if (update.Status == TenantConfigUpdateStatus.Updated)
|
||||
{
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
"UPDATE",
|
||||
@@ -403,10 +407,27 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
=> definitions.Select(d => TenantEntitlementService.NormalizePlanName(d.Name)).Where(n => n != null).Select(n => n!).OrderBy(n => n, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/config/{tenantId}/trial — TenantAdminOrGA (catalog-enforced). Self-service
|
||||
/// 30-day Pro trial, exactly once per tenant. 409 when the trial was already
|
||||
/// consumed or the tenant is already effectively Pro.
|
||||
/// The response for an <see cref="TenantConfigurationService.UpdateAsync"/> outcome that wrote
|
||||
/// nothing for a reason other than the caller's own decision; null for Updated and Declined.
|
||||
/// </summary>
|
||||
private static async Task<HttpResponseData?> UpdateFailureAsync(HttpRequestData req, TenantConfigUpdate update)
|
||||
{
|
||||
switch (update.Status)
|
||||
{
|
||||
case TenantConfigUpdateStatus.NotFound:
|
||||
return await req.NotFoundAsync("Tenant not found");
|
||||
case TenantConfigUpdateStatus.OffboardingInProgress:
|
||||
// The answer the suspension gate gives once its cache has caught up with the tombstone.
|
||||
return await req.ErrorAsync(HttpStatusCode.Forbidden,
|
||||
Constants.ApiErrorCodes.TenantSuspended, TenantOffboardFunction.OffboardingDisabledReason);
|
||||
case TenantConfigUpdateStatus.Conflict:
|
||||
return await req.ErrorAsync(HttpStatusCode.Conflict,
|
||||
Constants.ApiErrorCodes.Conflict, "The tenant configuration was changed concurrently. Retry shortly.");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure verdict for the self-service trial start; null = allowed. Order matters and is
|
||||
/// test-pinned: the terminal conditions (trial consumed, already Pro) win over the
|
||||
@@ -453,6 +474,12 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
return missing;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/config/{tenantId}/trial — TenantAdminOrGA (catalog-enforced). Self-service
|
||||
/// 30-day Pro trial, exactly once per tenant — also across offboarding and re-onboarding
|
||||
/// (the offboarding pointer carries <see cref="TenantConfiguration.TrialConsumed"/> over).
|
||||
/// 409 when the trial was already consumed or the tenant is already effectively Pro.
|
||||
/// </summary>
|
||||
[Function("StartTenantTrial")]
|
||||
public async Task<HttpResponseData> StartTrial(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "config/{tenantId}/trial")] HttpRequestData req,
|
||||
@@ -464,29 +491,31 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
var caller = requestCtx.UserPrincipalName ?? "Unknown";
|
||||
_logger.LogInformation("StartTenantTrial: tenantId={TenantId} by {User}", requestCtx.TargetTenantId, caller);
|
||||
|
||||
var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
|
||||
if (config == null)
|
||||
{
|
||||
return await req.NotFoundAsync("Tenant not found");
|
||||
}
|
||||
|
||||
var nowUtc = _time.GetUtcNow().UtcDateTime;
|
||||
(string Code, string Message)? deny = null;
|
||||
|
||||
if (EvaluateTrialStart(config, nowUtc) is { } deny)
|
||||
// The verdict runs on the fresh row inside the conditional write: two concurrent
|
||||
// starts cannot both see TrialConsumed=false, and a stale cached row is never saved.
|
||||
var update = await _configService.UpdateAsync(requestCtx.TargetTenantId, row =>
|
||||
{
|
||||
return await req.ErrorAsync(HttpStatusCode.Conflict, deny.Code, deny.Message);
|
||||
}
|
||||
deny = EvaluateTrialStart(row, nowUtc);
|
||||
if (deny != null)
|
||||
return false;
|
||||
row.TrialStartedUtc = nowUtc;
|
||||
row.TrialExpiresUtc = nowUtc.AddDays(SelfServiceTrialDays);
|
||||
row.TrialConsumed = true;
|
||||
row.TrialGrantedBy = caller;
|
||||
// Effectively Pro again — a leftover retention grace anchor is obsolete.
|
||||
row.ProDowngradedUtc = null;
|
||||
row.UpdatedBy = caller;
|
||||
return true;
|
||||
}, "plan", "self-service trial start");
|
||||
|
||||
config.TrialStartedUtc = nowUtc;
|
||||
config.TrialExpiresUtc = nowUtc.AddDays(SelfServiceTrialDays);
|
||||
config.TrialConsumed = true;
|
||||
config.TrialGrantedBy = caller;
|
||||
// Effectively Pro again — a leftover retention grace anchor is obsolete.
|
||||
config.ProDowngradedUtc = null;
|
||||
config.UpdatedBy = caller;
|
||||
|
||||
// Fail-loud: throws when the write did not persist (cache invalidated in finally).
|
||||
await _configService.SaveConfigurationAsync(config, "plan", "self-service trial start");
|
||||
if (await UpdateFailureAsync(req, update) is { } failure)
|
||||
return failure;
|
||||
if (deny is { } refused)
|
||||
return await req.ErrorAsync(HttpStatusCode.Conflict, refused.Code, refused.Message);
|
||||
var config = update.Config!;
|
||||
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
|
||||
@@ -8,6 +8,7 @@ using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Offboarding;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -36,6 +37,7 @@ public class AuthFunction
|
||||
private readonly AdminIdentityResolver _identityResolver;
|
||||
private readonly ISignalRNotificationService _signalRService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly IOffboardingAuditRepository _offboardingRepo;
|
||||
|
||||
public AuthFunction(
|
||||
ILogger<AuthFunction> logger,
|
||||
@@ -52,9 +54,11 @@ public class AuthFunction
|
||||
EntraAppRegistry appRegistry,
|
||||
AdminIdentityResolver identityResolver,
|
||||
ISignalRNotificationService signalRService,
|
||||
AdminConfigurationService adminConfigService)
|
||||
AdminConfigurationService adminConfigService,
|
||||
IOffboardingAuditRepository offboardingRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_offboardingRepo = offboardingRepo;
|
||||
_adminConfigService = adminConfigService;
|
||||
_identityResolver = identityResolver;
|
||||
_signalRService = signalRService;
|
||||
@@ -382,6 +386,23 @@ public class AuthFunction
|
||||
return;
|
||||
}
|
||||
|
||||
// A returning tenant: the offboarding deleted its configuration, so the pointer is the only
|
||||
// record of a consumed self-service trial. Carried over in this same write as OnboardedBy, so
|
||||
// no onboarded row (and no auto-promoted admin) ever exists without it (D-288). Unreadable ⇒
|
||||
// no write at all; the self-gate above retries on the next login.
|
||||
OffboardingByTenantPointer? offboardingPointer;
|
||||
try
|
||||
{
|
||||
(offboardingPointer, _) = await _offboardingRepo.TryGetByTenantPointerAsync(tenantId.ToLowerInvariant());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "First-login offboarding lookup failed for tenant {TenantId} — will retry on next login", tenantId);
|
||||
return;
|
||||
}
|
||||
if (offboardingPointer is { TrialConsumed: true })
|
||||
tenantConfig.TrialConsumed = true;
|
||||
|
||||
_logger.LogInformation("Setting domain name for tenant {TenantId}: {Domain}", tenantId, domain);
|
||||
tenantConfig.DomainName = domain;
|
||||
tenantConfig.UpdatedBy = upn;
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Functions.Admin;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
@@ -137,8 +138,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// swallows storage exceptions and reports failure via its bool return, so ignoring it would
|
||||
/// let callers audit + return 200 for a save that never happened (Codex finding, 2026-07-07).
|
||||
/// The cached row is invalidated on EVERY attempted save (finally): on success it is stale;
|
||||
/// on failure the caller may have mutated the cached instance in place (plan/trial endpoints),
|
||||
/// which must not linger as phantom-saved state for the 5-minute TTL.
|
||||
/// on failure the caller may have mutated the cached instance in place, which must not linger
|
||||
/// as phantom-saved state for the 5-minute TTL. Callers that decide on the current state or
|
||||
/// could race another writer use <see cref="UpdateAsync"/> instead.
|
||||
/// </summary>
|
||||
public virtual Task SaveConfigurationAsync(TenantConfiguration config)
|
||||
=> SaveConfigurationAsync(config, backupSource: null, backupReason: null);
|
||||
@@ -180,6 +182,64 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
internal const int MaxUpdateAttempts = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Read-modify-write for a caller that persists the WHOLE entity and decides on its current
|
||||
/// state: <paramref name="mutate"/> runs on the FRESH, projected row and the write is
|
||||
/// conditional on that row's ETag. A cached view is up to 5 minutes stale per instance, so
|
||||
/// saving it would rewind whatever another writer changed meanwhile (the 2026-07-31
|
||||
/// HomedAppClientId class) — including the offboarding tombstone during its drain barrier.
|
||||
/// <para>
|
||||
/// On a lost race the row is re-read and <paramref name="mutate"/> runs again, so it must
|
||||
/// derive everything from the row it is handed. Returning false declines the write. The
|
||||
/// offboarding tombstone is never written: the cascade owns the row until it deletes it.
|
||||
/// Storage errors other than a lost race throw.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public virtual async Task<TenantConfigUpdate> UpdateAsync(
|
||||
string tenantId, Func<TenantConfiguration, bool> mutate, string backupSource, string backupReason)
|
||||
{
|
||||
for (var attempt = 1; attempt <= MaxUpdateAttempts; attempt++)
|
||||
{
|
||||
var read = await _configRepo.GetTenantConfigurationWithEtagAsync(tenantId);
|
||||
if (read == null)
|
||||
return new TenantConfigUpdate(TenantConfigUpdateStatus.NotFound, null);
|
||||
|
||||
var (config, etag) = read.Value;
|
||||
await ProjectAsync(config);
|
||||
|
||||
if (TenantOffboardFunction.IsOffboardingTombstone(config))
|
||||
return new TenantConfigUpdate(TenantConfigUpdateStatus.OffboardingInProgress, config);
|
||||
|
||||
if (!mutate(config))
|
||||
return new TenantConfigUpdate(TenantConfigUpdateStatus.Declined, config);
|
||||
|
||||
config.LastUpdated = DateTime.UtcNow;
|
||||
bool replaced;
|
||||
try
|
||||
{
|
||||
replaced = await _configRepo.TryReplaceTenantConfigurationAsync(config, etag, backupSource, backupReason);
|
||||
}
|
||||
finally
|
||||
{
|
||||
InvalidateCache(tenantId);
|
||||
}
|
||||
|
||||
if (replaced)
|
||||
{
|
||||
_logger.LogInformation("Configuration updated for tenant {TenantId} by {UpdatedBy}", tenantId, config.UpdatedBy);
|
||||
return new TenantConfigUpdate(TenantConfigUpdateStatus.Updated, config);
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"Configuration update for tenant {TenantId} lost the ETag race (attempt {Attempt}/{Max})",
|
||||
tenantId, attempt, MaxUpdateAttempts);
|
||||
}
|
||||
|
||||
return new TenantConfigUpdate(TenantConfigUpdateStatus.Conflict, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One-way seed of the tenant contact address: writes <paramref name="email"/> only while
|
||||
/// the tenant has none, and only if nothing else wrote the configuration in the meantime.
|
||||
@@ -342,4 +402,22 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum TenantConfigUpdateStatus
|
||||
{
|
||||
Updated,
|
||||
/// <summary>The mutation returned false; nothing was written.</summary>
|
||||
Declined,
|
||||
NotFound,
|
||||
/// <summary>The row is the offboarding tombstone; nothing was written.</summary>
|
||||
OffboardingInProgress,
|
||||
/// <summary>Every conditional write lost the ETag race.</summary>
|
||||
Conflict,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of <see cref="TenantConfigurationService.UpdateAsync"/>. <see cref="Config"/> is the
|
||||
/// row as written (Updated) or as read (Declined, OffboardingInProgress); null otherwise.
|
||||
/// </summary>
|
||||
public sealed record TenantConfigUpdate(TenantConfigUpdateStatus Status, TenantConfiguration? Config);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,14 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
/// </summary>
|
||||
Task<bool> TryReplaceTenantConfigurationAsync(TenantConfiguration config, string etag);
|
||||
|
||||
/// <summary>
|
||||
/// Conditional full replace with the SAME fail-soft pre-write backup as
|
||||
/// <see cref="SaveTenantConfigurationAsync(TenantConfiguration, string?, string?)"/> — the
|
||||
/// If-Match variant of the ordinary save, for read-modify-write callers without a
|
||||
/// fail-closed snapshot of their own. Return and throw semantics as the 2-arg overload.
|
||||
/// </summary>
|
||||
Task<bool> TryReplaceTenantConfigurationAsync(TenantConfiguration config, string etag, string? backupSource, string? backupReason);
|
||||
|
||||
Task<List<TenantConfiguration>> GetAllTenantConfigurationsAsync();
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -38,5 +38,10 @@ namespace AutopilotMonitor.Shared.Models.Offboarding
|
||||
/// <summary>How many distinct offboarding rounds this tenant has been through.
|
||||
/// Incremented on each new Initiate. Useful for "Tenant ging und kam zurück"-Reporting.</summary>
|
||||
public int OffboardCount { get; set; } = 1;
|
||||
|
||||
/// <summary>Sticky: true once any offboarded configuration had consumed the self-service Pro trial.
|
||||
/// The offboarding deletes that configuration, so this is the only record left; the first login of the
|
||||
/// returning tenant copies it onto the new configuration (the trial stays once per tenant).</summary>
|
||||
public bool TrialConsumed { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user