Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/AdminConfigurationKillSwitchUncachedReadTests.cs
T
Oliver Kieselbach 8134adf3e4 feat(backend,web): wire admin-delete + worker + UI for V2 cascade (PR5/7)
Plan §5 PR5 (session-deletion-cascade-safe.md): flag-gated dispatch in
DeleteSessionFunction between the legacy direct-delete path and the V2
cascade producer; SessionDeletionWorker registered as a hosted service;
UI shows a "deletion queued" toast + per-row spinner while the worker
drains, then removes the row on the SignalR sessionDeleted event.

Includes the codex review follow-ups:
  F1 — IsSessionDeletionKillSwitchActiveAsync bypasses the 5-min admin
       config cache; fail-CLOSED on storage error so a flip-ON is honored
       across scaled-out Function-host instances within seconds (instead
       of minutes). Wired into DeleteSessionFunction, Producer, Worker.
  F2 — DeleteSessionAsync now reads DeletionState + ETag together with
       IndexRowKey, refuses on locked state, and ETag-conditionals the
       delete; closes the T2-T4 race where a concurrent V2 producer
       could orphan side-table rows. Caller in the legacy path
       disambiguates the bool via a re-read into 404 / 409 / 500.
  F3 — pendingDeletions threaded from useDeleteSession through page.tsx
       → SessionTable → SessionCell; action cell renders a disabled
       spinner instead of the delete button. Hook subscribes to
       sessionDeleted SignalR + polls GET /api/sessions/{id} every 60s
       as a fallback for missed events; only 404 clears the pending row.

EnableCascadeDeleteV2 + SessionDeletionKillSwitch both default false —
PR5 stop-condition keeps real tenants on the legacy path until §18.4
internal-tenant validation. PR6 wires the producer into the dedicated
SessionDeletionMaintenanceFunction next.

Backend xUnit: 1483/1483 pass. Web vitest: 347/347 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:08:04 +02:00

96 lines
4.7 KiB
C#

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.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for <see cref="AdminConfigurationService.IsSessionDeletionKillSwitchActiveAsync"/>
/// — Plan §5 PR5 finding 1. The general-purpose <c>GetConfigurationAsync</c> caches for
/// 5 minutes per instance; that's wrong for an *emergency* switch. This helper bypasses the
/// cache so a flip-ON is honored across scaled-out Function-host instances within seconds,
/// and fails CLOSED on storage errors so a transient outage cannot accidentally enable
/// deletions during the incident the switch is mitigating.
/// </summary>
public class AdminConfigurationKillSwitchUncachedReadTests
{
[Fact]
public async Task IsSessionDeletionKillSwitchActiveAsync_returns_false_when_flag_is_false()
{
var repo = new Mock<IConfigRepository>();
repo.Setup(r => r.GetAdminConfigurationAsync())
.ReturnsAsync(new AdminConfiguration { SessionDeletionKillSwitch = false });
var sut = new AdminConfigurationService(repo.Object, NullLogger<AdminConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
Assert.False(await sut.IsSessionDeletionKillSwitchActiveAsync());
}
[Fact]
public async Task IsSessionDeletionKillSwitchActiveAsync_returns_true_when_flag_is_true()
{
var repo = new Mock<IConfigRepository>();
repo.Setup(r => r.GetAdminConfigurationAsync())
.ReturnsAsync(new AdminConfiguration { SessionDeletionKillSwitch = true });
var sut = new AdminConfigurationService(repo.Object, NullLogger<AdminConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
Assert.True(await sut.IsSessionDeletionKillSwitchActiveAsync());
}
[Fact]
public async Task IsSessionDeletionKillSwitchActiveAsync_bypasses_the_5min_cache()
{
// Pre-populate the cached path with flag=false so a cached read would return false.
// Then have the repo return flag=true. The uncached path MUST observe true — that's the
// whole point: the cache must not hide an emergency switch flip across instances.
var cache = new MemoryCache(new MemoryCacheOptions());
var repo = new Mock<IConfigRepository>();
var flag = false;
repo.Setup(r => r.GetAdminConfigurationAsync())
.ReturnsAsync(() => new AdminConfiguration { SessionDeletionKillSwitch = flag });
var sut = new AdminConfigurationService(repo.Object, NullLogger<AdminConfigurationService>.Instance, cache);
// Warm the cache via the general getter — now the cache holds flag=false.
var cached = await sut.GetConfigurationAsync();
Assert.False(cached.SessionDeletionKillSwitch);
// Operator flips the kill switch at the storage layer (other instance saves it).
flag = true;
// The cached reader would still see false — but the uncached helper MUST see true.
var cachedAfter = await sut.GetConfigurationAsync();
Assert.False(cachedAfter.SessionDeletionKillSwitch); // proves the cache is still serving stale
Assert.True(await sut.IsSessionDeletionKillSwitchActiveAsync()); // uncached read sees reality
}
[Fact]
public async Task IsSessionDeletionKillSwitchActiveAsync_returns_false_when_no_config_row_exists()
{
// Repo returns null (no admin-config row yet) — treat as "switch not active" so a
// brand-new tenant install isn't 503'd into a non-functional state.
var repo = new Mock<IConfigRepository>();
repo.Setup(r => r.GetAdminConfigurationAsync()).ReturnsAsync((AdminConfiguration?)null);
var sut = new AdminConfigurationService(repo.Object, NullLogger<AdminConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
Assert.False(await sut.IsSessionDeletionKillSwitchActiveAsync());
}
[Fact]
public async Task IsSessionDeletionKillSwitchActiveAsync_fails_CLOSED_on_storage_exception()
{
// Storage is down — for an emergency switch, blocking new deletes is the safe default.
// Returning false here would be the dangerous case: deletes proceed during an incident.
var repo = new Mock<IConfigRepository>();
repo.Setup(r => r.GetAdminConfigurationAsync()).ThrowsAsync(new System.Exception("storage unavailable"));
var sut = new AdminConfigurationService(repo.Object, NullLogger<AdminConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
Assert.True(await sut.IsSessionDeletionKillSwitchActiveAsync());
}
}