Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/ConfigBackupRepositoryTests.cs
T
Oliver Kieselbach 395fdd49df Transactional tenant-config writes via MCP + ConfigurationBackups safety net (#120)
* feat(backend): pre-write config backup net - every tenant/admin config save snapshots first

New ConfigurationBackups table (PK=tenantId or GlobalConfig, RK=reverse-ticks+guid,
newest-first) holds the raw stored row as EntityJson before any overwriting save of
TenantConfiguration or AdminConfiguration. Hook lives in TableConfigRepository, the
single choke point all writers funnel through - fail-soft (a backup hiccup never
breaks a save), skips row creations and noise-only changes (LastAuthClientId flips),
prunes to the newest 2 per partition. Source/reason tagging via a new
SaveTenantConfigurationAsync overload (not optional params - Moq expression trees,
CS0854); portal-put/plan/auth/app-homing/offboard call sites tagged.

Offboarding: backup partition wiped in the bulk phase (TenantPartitionTables) and by
the marker-cleanup tombstone sweep; the GlobalConfig partition never matches a tenant
GUID and survives. Table registered in TableNames.All + CriticalBackupTables.All.

Groundwork for the transactional MCP config-write flow (patch/revert endpoints follow).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(backend): transactional tenant-config field patch + revert with verified writes

New GA-only surface (policy catalog: GlobalAdminOnly + RouteParam):
- PATCH config/{tenantId}/fields - field-level patch through TenantConfigPatchService:
  fresh ETag read -> field gate (deny-list incl. plan/trial, HomedAppClientId,
  auth provenance; unknown fields 400 with the name) -> patch onto a clone ->
  shared validation -> fail-CLOSED backup -> CAS replace -> fresh re-read ->
  verify that EXACTLY the intended fields changed -> automatic ETag-conditional
  rollback on drift (catches Store/Map serialization asymmetry).
- POST config/{tenantId}/revert - restore from a ConfigurationBackups snapshot
  (latest by default); snapshots current state first (revert is revertible),
  preserves plan/homing/provenance fields unless includeProtectedFields.
- GET config/{tenantId}/backups - metadata + masked diff only, never EntityJson.

Supporting changes: IConfigRepository gains GetTenantConfigurationWithEtagAsync +
TryReplaceTenantConfigurationAsync (412 -> false, no implicit backup hook);
validators moved from the PUT into shared TenantConfigValidation (PUT re-pointed,
forwarding shims keep test call sites); GET config/{tenantId} honors ?view=redacted
so a GA's MCP session never pulls clear-text secrets into model context.
Caller-tier deny-lists (TenantAdmin/DelegatedAdmin) ship now for the phase-2 flip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): tenant-config write tools - update/revert with snapshot-backed verified writes

First mutating surface on the MCP server, strictGa (real Global Admin) only:
- get_tenant_config: full config read, ALWAYS ?view=redacted (secrets never
  reach model context; pinned by config-write-tools tests)
- update_tenant_config: field-level patch via PATCH config/{tenantId}/fields;
  required reason; response = applied fields + masked diff + backupId
- list_tenant_config_backups: snapshot metadata + masked diff only
- revert_tenant_config: restore latest/named snapshot; protected fields stay
  current unless includeProtectedFields; revert is itself revertible

New MUTATING annotation + TenantGuidSchema (path-traversal guard) in shared.ts;
role-aware instructions headline (non-GA keeps the exact READ-ONLY wording);
catalog snapshot test extends GA_FULL + new CONFIG_WRITE_GA_STRICT difference
list (Global Reader/tenant/delegated see none of the four).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(backend): de-flake session-deletion heartbeat test - condition-gated handler instead of wall-clock window

The heartbeat test held the handler for a FIXED 350ms and expected the 50ms
heartbeat timer to tick >=3 times inside that window. On loaded CI runners the
heartbeat Task.Delay stretches disproportionately to the handler delay, the
handler completes with <3 heartbeats fired, and the awaited condition can never
become true afterwards (CI failure on PR #120). The handler now holds the
message in-flight UNTIL the test observed 3 heartbeats (with ct + own 15s
deadline as termination guards), so the assertion is reached deterministically
regardless of scheduler jitter. Verified: 12/12 local runs green, full suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 14:30:05 +02:00

173 lines
6.6 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Data.Tables;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// TableConfigBackupRepository: Store↔Map roundtrip (project rule "table-serialization"),
/// reverse-ticks RowKey ordering (newest-first), and prune-beyond-keep semantics.
/// </summary>
public class ConfigBackupRepositoryTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private sealed class Harness
{
public TableConfigBackupRepository Sut { get; }
public Mock<TableClient> Table { get; } = new();
public List<TableEntity> Upserts { get; } = new();
public List<(string Pk, string Rk)> Deletes { get; } = new();
public Harness()
{
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.DeleteEntityAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ETag>(), It.IsAny<CancellationToken>()))
.Returns<string, string, ETag, CancellationToken>((pk, rk, _, _) =>
{
Deletes.Add((pk, rk));
return Task.FromResult(Mock.Of<Response>());
});
var serviceClient = new Mock<TableServiceClient>();
serviceClient.Setup(c => c.GetTableClient(It.IsAny<string>())).Returns(Table.Object);
Sut = new TableConfigBackupRepository(
new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance),
NullLogger<TableConfigBackupRepository>.Instance);
}
public void SetupQuery(params TableEntity[] rows)
{
var page = Page<TableEntity>.FromValues(rows, continuationToken: null, Mock.Of<Response>());
Table.Setup(c => c.QueryAsync<TableEntity>(
It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
.Returns(AsyncPageable<TableEntity>.FromPages(new[] { page }));
}
}
private static ConfigBackupEntry Entry(string rowKey = "0001_abc") => new()
{
PartitionKey = TenantId,
RowKey = rowKey,
TenantId = TenantId,
EntityJson = "{\"DomainName\":\"contoso.com\"}",
ChangedBy = "ga@operator.example",
Source = "mcp-patch",
Reason = "retention bump",
DiffJson = "{\"DataRetentionDays\":\"30 \\u2192 90\"}",
BackupTakenAt = new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc),
};
[Fact]
public async Task Roundtrip_StoreAndMap_PreservesEveryField()
{
var harness = new Harness();
var entry = Entry();
await harness.Sut.UpsertAsync(entry);
var stored = Assert.Single(harness.Upserts);
// Feed the stored entity back through the read path (TryGetAsync → Map).
harness.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>()));
var mapped = await harness.Sut.TryGetAsync(TenantId, entry.RowKey);
Assert.NotNull(mapped);
Assert.Equal(entry.PartitionKey, mapped!.PartitionKey);
Assert.Equal(entry.RowKey, mapped.RowKey);
Assert.Equal(entry.TenantId, mapped.TenantId);
Assert.Equal(entry.EntityJson, mapped.EntityJson);
Assert.Equal(entry.ChangedBy, mapped.ChangedBy);
Assert.Equal(entry.Source, mapped.Source);
Assert.Equal(entry.Reason, mapped.Reason);
Assert.Equal(entry.DiffJson, mapped.DiffJson);
Assert.Equal(entry.BackupTakenAt, mapped.BackupTakenAt);
}
[Fact]
public void BuildRowKey_LaterTimestamps_SortFirst()
{
var earlier = TableConfigBackupRepository.BuildRowKey(new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc));
var later = TableConfigBackupRepository.BuildRowKey(new DateTime(2026, 8, 3, 12, 0, 1, DateTimeKind.Utc));
// Reverse ticks: the LATER backup gets the LEXICOGRAPHICALLY SMALLER RowKey, so
// Azure's RowKey-ascending partition scan returns newest-first.
Assert.True(string.CompareOrdinal(later, earlier) < 0);
}
[Fact]
public void BuildRowKey_SameTick_DistinctViaGuidSuffix()
{
var at = new DateTime(2026, 8, 3, 12, 0, 0, DateTimeKind.Utc);
Assert.NotEqual(
TableConfigBackupRepository.BuildRowKey(at),
TableConfigBackupRepository.BuildRowKey(at));
}
[Fact]
public async Task Prune_DeletesEverythingBeyondKeep_InScanOrder()
{
var harness = new Harness();
// Scan order = RowKey ascending = newest first (reverse ticks). keep=2 must
// delete rows 3 and 4, never the two newest.
harness.SetupQuery(
new TableEntity(TenantId, "0001_newest"),
new TableEntity(TenantId, "0002_second"),
new TableEntity(TenantId, "0003_old"),
new TableEntity(TenantId, "0004_oldest"));
var deleted = await harness.Sut.PruneAsync(TenantId, keep: 2);
Assert.Equal(2, deleted);
Assert.Equal(new[] { "0003_old", "0004_oldest" }, harness.Deletes.Select(d => d.Rk).ToArray());
}
[Fact]
public async Task Prune_WithinKeep_DeletesNothing()
{
var harness = new Harness();
harness.SetupQuery(
new TableEntity(TenantId, "0001_newest"),
new TableEntity(TenantId, "0002_second"));
var deleted = await harness.Sut.PruneAsync(TenantId, keep: 2);
Assert.Equal(0, deleted);
Assert.Empty(harness.Deletes);
}
[Fact]
public async Task ListByPartition_CapsAtMax()
{
var harness = new Harness();
harness.SetupQuery(
new TableEntity(TenantId, "0001"),
new TableEntity(TenantId, "0002"),
new TableEntity(TenantId, "0003"));
var listed = await harness.Sut.ListByPartitionAsync(TenantId, max: 2);
Assert.Equal(2, listed.Count);
Assert.Equal("0001", listed[0].RowKey);
}
}