Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/ConfigRepoMockExtensions.cs
T
Oliver Kieselbach e816c92b88 Tenant configuration has one conditional write path; the full-model PUT is gone
A tenant configuration row is never written blind any more (D-290). Three writers saved
from the 5-minute instance cache and four wrote a fresh read without an ETag: auto-re-enable
could lift a suspension a Global Admin had just renewed, the PUT could write the cached
Disabled=false over the offboarding tombstone during the drain barrier, and a concurrent
first login could overwrite the write-once OnboardedBy.

- TenantConfigurationService.CreateOrUpdateAsync next to UpdateAsync (shared core): fresh
  read with ETag, tombstone refused, mutation decides, conditional replace, conditional
  insert for a missing row, re-read and re-decide on a lost race.
- SaveConfigurationAsync and IConfigRepository.SaveTenantConfigurationAsync deleted; new
  TryCreateTenantConfigurationAsync. The default row of GetConfigurationAsync is a
  conditional insert.
- Converted: first-login seed (writes only while the stored row has no domain; only the
  winning login fires the signup side effects), auto-re-enable (decided on the fresh row),
  auth client-id tracking, app-homing flip, conferred-Pro grace stamp, offboarding tombstone.
- PUT/POST config/{tenantId} removed (function, catalog entries, response DTO). The Global
  Admin tenant editor sends only its changed fields to the field PATCH; the Collect Logs
  quick-config PATCHes its two diagnostics fields with ?intent=collect-logs, which keeps
  the CollectLogsQuickConfigEnabled ops event. Shared diff helper lib/tenantConfigSave.ts.
- Tests: create-or-update and default-row races, concurrent first login, renewed
  suspension, tombstone paths; repository and service doubles in ConfigRepoMockExtensions.
  Regenerated shared manifest and wire types (web, MCP).

Deploy order: backend, then web right after (the old web's editor save and quick-config
use the removed PUT).

Bumps internal (D-290, plan, docs).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 20:11:56 +02:00

133 lines
6.6 KiB
C#

using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
internal static class ConfigRepoMockExtensions
{
/// <summary>
/// Asserts that no tenant configuration row was created or replaced — the only two ways the
/// repository can write one (D-290).
/// </summary>
public static void VerifyNoTenantConfigWrite(this Mock<IConfigRepository> repo)
{
repo.Verify(r => r.TryCreateTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
repo.Verify(r => r.TryReplaceTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string>()), Times.Never);
repo.Verify(r => r.TryReplaceTenantConfigurationAsync(
It.IsAny<TenantConfiguration>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
}
/// <summary>
/// Backs <see cref="TenantConfigurationService.UpdateAsync"/> and
/// <see cref="TenantConfigurationService.CreateOrUpdateAsync"/> of a service mock with one stored row
/// and the real contract: the mutation sees a fresh copy (never the caller's object), the offboarding
/// tombstone is refused, a declined mutation writes nothing, and every write is recorded.
/// </summary>
public static StoredTenantConfig StubWrites(this Mock<TenantConfigurationService> service, string tenantId, TenantConfiguration? row)
{
var store = new StoredTenantConfig { Row = row };
Task<TenantConfigUpdate> Run(Func<TenantConfiguration, bool> mutate, string source, string reason, bool createIfMissing)
{
if (store.Force is { } forced)
return Task.FromResult(new TenantConfigUpdate(forced, null));
if (store.ThrowOnRead is { } ex)
throw ex;
TenantConfiguration fresh;
if (store.Row == null)
{
if (!createIfMissing)
return Task.FromResult(new TenantConfigUpdate(TenantConfigUpdateStatus.NotFound, null));
fresh = TenantConfiguration.CreateDefault(tenantId);
}
else
{
fresh = Clone(store.Row);
if (TenantOffboardFunction.IsOffboardingTombstone(fresh))
return Task.FromResult(new TenantConfigUpdate(TenantConfigUpdateStatus.OffboardingInProgress, fresh));
}
if (!mutate(fresh))
return Task.FromResult(new TenantConfigUpdate(TenantConfigUpdateStatus.Declined, fresh));
fresh.LastUpdated = DateTime.UtcNow;
store.Row = fresh;
store.Writes.Add((fresh, source, reason));
return Task.FromResult(new TenantConfigUpdate(TenantConfigUpdateStatus.Updated, fresh));
}
service.Setup(x => x.UpdateAsync(tenantId, It.IsAny<Func<TenantConfiguration, bool>>(), It.IsAny<string>(), It.IsAny<string>()))
.Returns((string _, Func<TenantConfiguration, bool> mutate, string source, string reason) => Run(mutate, source, reason, false));
service.Setup(x => x.CreateOrUpdateAsync(tenantId, It.IsAny<Func<TenantConfiguration, bool>>(), It.IsAny<string>(), It.IsAny<string>()))
.Returns((string _, Func<TenantConfiguration, bool> mutate, string source, string reason) => Run(mutate, source, reason, true));
return store;
}
/// <summary>
/// Backs the tenant-configuration reads and writes of a repository mock with one stored row and
/// the storage contract: every read hands back a fresh copy, the replace succeeds only with the
/// current ETag, the insert only while no row exists, and every write is recorded.
/// </summary>
public static StoredTenantConfig BackWithRow(this Mock<IConfigRepository> repo, TenantConfiguration? row)
{
var store = new StoredTenantConfig { Row = row };
var version = 0;
string ETag() => $"etag-{version}";
TenantConfiguration? Read()
{
if (store.ThrowOnRead is { } ex) throw ex;
return store.Row == null ? null : Clone(store.Row);
}
bool Write(TenantConfiguration config, string source, string reason)
{
if (store.ThrowOnWrite is { } ex) throw ex;
if (store.RejectWrites) return false;
store.Row = Clone(config);
version++;
store.Writes.Add((store.Row, source, reason));
return true;
}
repo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>())).ReturnsAsync(() => Read());
repo.Setup(r => r.GetTenantConfigurationWithEtagAsync(It.IsAny<string>()))
.ReturnsAsync(() => Read() is { } fresh ? (fresh, ETag()) : null);
repo.Setup(r => r.TryReplaceTenantConfigurationAsync(
It.IsAny<TenantConfiguration>(), It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync((TenantConfiguration c, string etag, string? source, string? reason) =>
store.Row != null && etag == ETag() && Write(c, source ?? "", reason ?? ""));
repo.Setup(r => r.TryCreateTenantConfigurationAsync(It.IsAny<TenantConfiguration>()))
.ReturnsAsync((TenantConfiguration c) => store.Row == null && Write(c, "(create)", ""));
return store;
}
/// <summary>A storage round trip: what a fresh read of the stored row hands back.</summary>
public static TenantConfiguration Clone(TenantConfiguration config)
=> TableConfigRepository.ConvertFromTenantTableEntity(TableConfigRepository.ConvertToTenantTableEntity(config));
}
/// <summary>The stored row behind <see cref="ConfigRepoMockExtensions.StubWrites"/> and what was written to it.</summary>
internal sealed class StoredTenantConfig
{
public TenantConfiguration? Row { get; set; }
public List<(TenantConfiguration Row, string Source, string Reason)> Writes { get; } = new();
/// <summary>When set, every write returns this status without touching the row (e.g. Conflict).</summary>
public TenantConfigUpdateStatus? Force { get; set; }
/// <summary>When set, every read (and, for the service stub, every write) throws it — a storage outage.</summary>
public Exception? ThrowOnRead { get; set; }
/// <summary>Repository double only: when set, every write throws it.</summary>
public Exception? ThrowOnWrite { get; set; }
/// <summary>Repository double only: every conditional write loses (as if a concurrent writer always won).</summary>
public bool RejectWrites { get; set; }
}