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

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));
}
}