Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/ConfigBackupHookTests.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

264 lines
11 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Data.Tables;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The universal pre-write snapshot hook in <see cref="TableConfigRepository"/>:
/// every overwriting save of a TenantConfiguration/AdminConfiguration row must first
/// snapshot the stored row into ConfigurationBackups — except row creations and
/// noise-only changes — and a backup-storage failure must never break the save (fail-soft).
/// </summary>
public class ConfigBackupHookTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private sealed class Harness
{
public TableConfigRepository Sut { get; }
public Mock<TableClient> Table { get; } = new();
public Mock<IConfigBackupRepository> Backup { get; } = new();
public List<TableEntity> Upserts { get; } = new();
public Harness(TableEntity? stored)
{
if (stored == null)
{
Table.Setup(c => c.GetEntityAsync<TableEntity>(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException(404, "Not Found", "ResourceNotFound", null));
}
else
{
Table.Setup(c => c.GetEntityAsync<TableEntity>(
It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(stored, Mock.Of<Response>()));
}
Table.Setup(c => c.UpsertEntityAsync(
It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
.Returns<TableEntity, TableUpdateMode, CancellationToken>((e, _, _) =>
{
Upserts.Add(e);
return Task.FromResult(Mock.Of<Response>());
});
Table.Setup(c => c.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
.Returns<TableEntity, CancellationToken>((e, _) =>
{
Upserts.Add(e);
return Task.FromResult(Mock.Of<Response>());
});
Table.Setup(c => c.UpdateEntityAsync(
It.IsAny<TableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, _, _, _) =>
{
Upserts.Add(e);
return Task.FromResult(Mock.Of<Response>());
});
var serviceClient = new Mock<TableServiceClient>();
serviceClient.Setup(c => c.GetTableClient(It.IsAny<string>())).Returns(Table.Object);
Sut = new TableConfigRepository(
new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance),
Backup.Object,
NullLogger<TableConfigRepository>.Instance);
}
}
private static TenantConfiguration StoredConfig()
{
var config = TenantConfiguration.CreateDefault(TenantId);
config.DomainName = "contoso.com";
config.UpdatedBy = "admin@contoso.com";
config.DataRetentionDays = 30;
config.TeamsWebhookUrl = "https://contoso.webhook.office.com/hook";
return config;
}
/// <summary>
/// Mirrors production: every real writer round-trips the STORED row (read-modify-write),
/// so time-stamped fields like OnboardedAt match the stored values exactly. Constructing
/// a second CreateDefault instead would fabricate an OnboardedAt diff that cannot occur.
/// </summary>
private static TenantConfiguration RoundtripClone(TenantConfiguration stored)
=> TableConfigRepository.ConvertFromTenantTableEntity(
TableConfigRepository.ConvertToTenantTableEntity(stored));
[Fact]
public async Task RealChange_SnapshotsStoredRow_ThenSaves_ThenPrunes()
{
var stored = StoredConfig();
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
var incoming = RoundtripClone(stored);
incoming.DataRetentionDays = 90;
incoming.UpdatedBy = "ga@operator.example";
ConfigBackupEntry? snapshot = null;
harness.Backup
.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => snapshot = e)
.Returns(Task.CompletedTask);
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "api-patch", "test change");
Assert.True(saved);
Assert.Single(harness.Upserts); // the config row itself
Assert.NotNull(snapshot);
Assert.Equal(TenantId, snapshot!.PartitionKey);
Assert.Equal("api-patch", snapshot.Source);
Assert.Equal("test change", snapshot.Reason);
Assert.Equal("ga@operator.example", snapshot.ChangedBy); // identity about to write
// The snapshot holds the STORED row (retention 30), not the incoming one.
Assert.Contains("\"DataRetentionDays\":30", snapshot.EntityJson);
// Raw secrets ARE in EntityJson (full-fidelity restore source)…
Assert.Contains("contoso.webhook.office.com", snapshot.EntityJson);
// …but the advisory diff masks them by property-name heuristics (ConfigDiffHelper).
Assert.DoesNotContain("contoso.webhook.office.com", snapshot.DiffJson ?? string.Empty);
harness.Backup.Verify(
b => b.PruneAsync(TenantId, Constants.ConfigBackupKeepCount, It.IsAny<CancellationToken>()),
Times.Once);
}
[Fact]
public async Task RowCreation_ConditionalInsert_NoSnapshot()
{
var harness = new Harness(stored: null);
var saved = await harness.Sut.TryCreateTenantConfigurationAsync(StoredConfig());
Assert.True(saved);
Assert.Single(harness.Upserts);
harness.Backup.Verify(
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
harness.Backup.Verify(
b => b.PruneAsync(It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task RowCreation_RowAlreadyExists_ReturnsFalse()
{
var harness = new Harness(stored: null);
harness.Table.Setup(c => c.AddEntityAsync(It.IsAny<TableEntity>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException(409, "Conflict", "EntityAlreadyExists", null));
Assert.False(await harness.Sut.TryCreateTenantConfigurationAsync(StoredConfig()));
}
[Fact]
public async Task NoiseOnlyChange_AuthClientIdFlip_NoSnapshot()
{
var stored = StoredConfig();
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
// The auth-side-effect write: only LastAuthClientId/Since + bookkeeping move.
var incoming = RoundtripClone(stored);
incoming.LastAuthClientId = "886ab5e2-6144-442c-80cc-9b28e0667731";
incoming.LastAuthClientIdSince = DateTime.UtcNow;
incoming.LastUpdated = DateTime.UtcNow;
incoming.UpdatedBy = "System (auth)";
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "auth", null);
Assert.True(saved); // the save itself still goes through
Assert.Single(harness.Upserts);
harness.Backup.Verify(
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task NoOpSave_IdenticalContent_NoSnapshot()
{
var stored = StoredConfig();
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(RoundtripClone(stored), "\"etag\"", "api-patch", null);
Assert.True(saved);
harness.Backup.Verify(
b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task BackupStorageFailure_IsFailSoft_SaveStillSucceeds()
{
var stored = StoredConfig();
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
harness.Backup
.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new RequestFailedException(503, "backup table throttled"));
var incoming = RoundtripClone(stored);
incoming.DataRetentionDays = 90;
var saved = await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", "api-patch", null);
Assert.True(saved);
Assert.Single(harness.Upserts); // the config write went through regardless
}
[Fact]
public async Task NoSource_StillSnapshots_WithUnknownSource()
{
var stored = StoredConfig();
var harness = new Harness(TableConfigRepository.ConvertToTenantTableEntity(stored));
ConfigBackupEntry? snapshot = null;
harness.Backup
.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => snapshot = e)
.Returns(Task.CompletedTask);
var incoming = RoundtripClone(stored);
incoming.DataRetentionDays = 90;
await harness.Sut.TryReplaceTenantConfigurationAsync(incoming, "\"etag\"", null, null);
Assert.NotNull(snapshot);
Assert.Equal("unknown", snapshot!.Source);
}
[Fact]
public async Task AdminConfigSave_SnapshotsUnderGlobalConfigPartition()
{
var stored = new AdminConfiguration
{
UpdatedBy = "ga@operator.example",
GlobalRateLimitRequestsPerMinute = 100,
};
var harness = new Harness(TableConfigRepository.ConvertToAdminTableEntity(stored));
ConfigBackupEntry? snapshot = null;
harness.Backup
.Setup(b => b.UpsertAsync(It.IsAny<ConfigBackupEntry>(), It.IsAny<CancellationToken>()))
.Callback<ConfigBackupEntry, CancellationToken>((e, _) => snapshot = e)
.Returns(Task.CompletedTask);
var result = await harness.Sut.UpdateAdminConfigurationAsync(
c => { c.GlobalRateLimitRequestsPerMinute = 200; return null; }, "ga@operator.example");
Assert.Null(result.Error);
Assert.Equal(new[] { "GlobalRateLimitRequestsPerMinute" }, result.ChangedColumns);
Assert.NotNull(snapshot);
Assert.Equal("GlobalConfig", snapshot!.PartitionKey);
Assert.Equal("admin-config", snapshot.Source);
harness.Backup.Verify(
b => b.PruneAsync("GlobalConfig", Constants.ConfigBackupKeepCount, It.IsAny<CancellationToken>()),
Times.Once);
}
}