mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +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>
157 lines
6.6 KiB
C#
157 lines
6.6 KiB
C#
using System;
|
|
using System.Threading.Tasks;
|
|
using AutopilotMonitor.Functions.Services;
|
|
using AutopilotMonitor.Shared.DataAccess;
|
|
using AutopilotMonitor.Shared.Models;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using Microsoft.Extensions.Logging;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests;
|
|
|
|
/// <summary>
|
|
/// Pins the two point-read semantics of <see cref="TenantConfigurationService"/>:
|
|
/// <see cref="TenantConfigurationService.GetConfigurationIfExistsAsync"/> PROPAGATES storage
|
|
/// failures (a caller like the delegated config/all subset must fail the request rather than
|
|
/// silently drop a tenant), while <see cref="TenantConfigurationService.TryGetConfigurationAsync"/>
|
|
/// maps them to exists=false (agent security gates fail closed). Both are non-creating.
|
|
/// </summary>
|
|
public sealed class TenantConfigurationServiceReadSemanticsTests
|
|
{
|
|
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
|
|
|
private static (TenantConfigurationService service, Mock<IConfigRepository> repo) Build()
|
|
{
|
|
var repo = new Mock<IConfigRepository>(MockBehavior.Strict);
|
|
var service = new TenantConfigurationService(
|
|
repo.Object,
|
|
Mock.Of<ILogger<TenantConfigurationService>>(),
|
|
new MemoryCache(new MemoryCacheOptions()));
|
|
return (service, repo);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetConfigurationIfExistsAsync_StorageError_Propagates()
|
|
{
|
|
var (service, repo) = Build();
|
|
repo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
|
.ThrowsAsync(new InvalidOperationException("throttled"));
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => service.GetConfigurationIfExistsAsync(TenantId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetConfigurationIfExistsAsync_NoRow_ReturnsNull_NeverCreates()
|
|
{
|
|
var (service, repo) = Build();
|
|
repo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
|
.ReturnsAsync((TenantConfiguration?)null);
|
|
|
|
Assert.Null(await service.GetConfigurationIfExistsAsync(TenantId));
|
|
// MockBehavior.Strict: any SaveTenantConfigurationAsync call would have thrown.
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetConfigurationIfExistsAsync_RowFound_ReturnsAndCaches()
|
|
{
|
|
var (service, repo) = Build();
|
|
repo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
|
.ReturnsAsync(new TenantConfiguration { TenantId = TenantId });
|
|
|
|
var first = await service.GetConfigurationIfExistsAsync(TenantId);
|
|
var second = await service.GetConfigurationIfExistsAsync(TenantId);
|
|
|
|
Assert.Equal(TenantId, first!.TenantId);
|
|
Assert.Same(first, second);
|
|
repo.Verify(r => r.GetTenantConfigurationAsync(TenantId), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetConfigurationIfExistsAsync_EmptyTenantId_ReturnsNull()
|
|
{
|
|
var (service, _) = Build();
|
|
Assert.Null(await service.GetConfigurationIfExistsAsync(""));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryGetConfigurationAsync_StorageError_FailsSafe_ExistsFalse()
|
|
{
|
|
var (service, repo) = Build();
|
|
repo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
|
.ThrowsAsync(new InvalidOperationException("throttled"));
|
|
|
|
var (config, exists) = await service.TryGetConfigurationAsync(TenantId);
|
|
|
|
Assert.False(exists);
|
|
Assert.Equal(TenantId, config.TenantId); // fail-safe default, not a stored row
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryGetConfigurationAsync_RowFound_ExistsTrue()
|
|
{
|
|
var (service, repo) = Build();
|
|
repo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
|
.ReturnsAsync(new TenantConfiguration { TenantId = TenantId });
|
|
|
|
var (config, exists) = await service.TryGetConfigurationAsync(TenantId);
|
|
|
|
Assert.True(exists);
|
|
Assert.Equal(TenantId, config.TenantId);
|
|
}
|
|
|
|
// ── Save semantics (Codex finding 2026-07-07): the repository swallows storage exceptions
|
|
// and reports failure via its bool return — the service must THROW on false so callers
|
|
// (plan/trial endpoints, config PUT) can never audit + 200 a write that never persisted.
|
|
|
|
[Fact]
|
|
public async Task SaveConfigurationAsync_RepoReportsFalse_Throws()
|
|
{
|
|
var (service, repo) = Build();
|
|
repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
|
.ReturnsAsync(false);
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => service.SaveConfigurationAsync(new TenantConfiguration { TenantId = TenantId, UpdatedBy = "alice@contoso.com" }));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SaveConfigurationAsync_FailedSave_InvalidatesCachedInstance()
|
|
{
|
|
// The plan/trial endpoints mutate the CACHED instance in place before saving. A failed
|
|
// save must drop that instance from the cache — otherwise the unsaved mutation is served
|
|
// as if persisted for up to 5 minutes.
|
|
var (service, repo) = Build();
|
|
var stored = new TenantConfiguration { TenantId = TenantId, PlanTier = "free", UpdatedBy = "x" };
|
|
repo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync(stored);
|
|
repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
|
.ReturnsAsync(false);
|
|
|
|
var cached = await service.GetConfigurationIfExistsAsync(TenantId);
|
|
cached!.PlanTier = "enterprise"; // in-place mutation, as the plan endpoint does
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => service.SaveConfigurationAsync(cached));
|
|
|
|
// Next read must go back to storage (cache dropped), not serve the mutated instance.
|
|
await service.GetConfigurationIfExistsAsync(TenantId);
|
|
repo.Verify(r => r.GetTenantConfigurationAsync(TenantId), Times.Exactly(2));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SaveConfigurationAsync_Success_InvalidatesCache()
|
|
{
|
|
var (service, repo) = Build();
|
|
repo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
|
.ReturnsAsync(new TenantConfiguration { TenantId = TenantId, UpdatedBy = "x" });
|
|
repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
|
.ReturnsAsync(true);
|
|
|
|
await service.GetConfigurationIfExistsAsync(TenantId); // prime cache
|
|
await service.SaveConfigurationAsync(new TenantConfiguration { TenantId = TenantId, UpdatedBy = "x" });
|
|
await service.GetConfigurationIfExistsAsync(TenantId); // must re-read after save
|
|
|
|
repo.Verify(r => r.GetTenantConfigurationAsync(TenantId), Times.Exactly(2));
|
|
}
|
|
}
|