mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
395fdd49df
* 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>
173 lines
6.6 KiB
C#
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);
|
|
}
|
|
}
|