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>
436 lines
19 KiB
C#
436 lines
19 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using AutopilotMonitor.Functions.Functions.Maintenance;
|
|
using AutopilotMonitor.Shared;
|
|
using AutopilotMonitor.Shared.DataAccess;
|
|
using AutopilotMonitor.Shared.Models;
|
|
using AutopilotMonitor.Shared.Models.Offboarding;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests.Offboarding;
|
|
|
|
/// <summary>
|
|
/// Rev-5-F2 + Rev-9 cleanup contract:
|
|
/// <list type="bullet">
|
|
/// <item>Completed markers older than 15min are removed — AFTER their Expectations blob.</item>
|
|
/// <item>Completed markers younger than 15min stay (warm caches could still leak auth).</item>
|
|
/// <item>Failed markers are NEVER auto-removed — operator action only.</item>
|
|
/// <item>Initiated / InProgress markers are ignored (offboarding still in flight).</item>
|
|
/// <item>Blob-delete failure leaves the marker in place so the next run retries.</item>
|
|
/// </list>
|
|
/// </summary>
|
|
public class OffboardingMarkerCleanupFunctionTests
|
|
{
|
|
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
|
private const string HistoryRowKey = "20260518091523123_11111111-1111-1111-1111-111111111111";
|
|
|
|
[Fact]
|
|
public async Task DeletesCompletedMarkerOlderThan15min()
|
|
{
|
|
var fixture = new Fixture();
|
|
fixture.Repo.Markers[TenantId] = NewMarker(status: "Completed",
|
|
completedAt: fixture.FixedNow - TimeSpan.FromMinutes(20));
|
|
fixture.Expectations.Seed(NewExpectations());
|
|
|
|
var result = await fixture.Sut.RunCoreAsync(default);
|
|
|
|
Assert.Equal(1, result.MarkersDeleted);
|
|
Assert.False(fixture.Repo.Markers.ContainsKey(TenantId));
|
|
Assert.False(fixture.Expectations.BlobExists(TenantId, HistoryRowKey),
|
|
"Expectations blob must be deleted before the marker");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task KeepsCompletedMarkerYoungerThan15min()
|
|
{
|
|
var fixture = new Fixture();
|
|
fixture.Repo.Markers[TenantId] = NewMarker(status: "Completed",
|
|
completedAt: fixture.FixedNow - TimeSpan.FromMinutes(5)); // inside cache TTL window
|
|
fixture.Expectations.Seed(NewExpectations());
|
|
|
|
var result = await fixture.Sut.RunCoreAsync(default);
|
|
|
|
Assert.Equal(0, result.MarkersDeleted);
|
|
Assert.True(fixture.Repo.Markers.ContainsKey(TenantId));
|
|
Assert.True(fixture.Expectations.BlobExists(TenantId, HistoryRowKey),
|
|
"Blob stays until the marker is eligible for removal");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NeverDeletesFailedMarker_EvenAfter24h()
|
|
{
|
|
var fixture = new Fixture();
|
|
fixture.Repo.Markers[TenantId] = NewMarker(
|
|
status: "Failed",
|
|
failedAt: fixture.FixedNow - TimeSpan.FromHours(24),
|
|
failedPhase: "drain_timeout");
|
|
|
|
var result = await fixture.Sut.RunCoreAsync(default);
|
|
|
|
Assert.Equal(0, result.MarkersDeleted);
|
|
Assert.Equal(1, result.FailedMarkersSeen);
|
|
Assert.True(fixture.Repo.Markers.ContainsKey(TenantId),
|
|
"Failed markers require operator action — never auto-cleaned");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IgnoresInProgressMarker()
|
|
{
|
|
var fixture = new Fixture();
|
|
fixture.Repo.Markers[TenantId] = NewMarker(status: "InProgress");
|
|
|
|
var result = await fixture.Sut.RunCoreAsync(default);
|
|
|
|
Assert.Equal(0, result.MarkersDeleted);
|
|
Assert.Equal(0, result.FailedMarkersSeen);
|
|
Assert.True(fixture.Repo.Markers.ContainsKey(TenantId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task IgnoresInitiatedMarker()
|
|
{
|
|
var fixture = new Fixture();
|
|
fixture.Repo.Markers[TenantId] = NewMarker(status: "Initiated");
|
|
|
|
var result = await fixture.Sut.RunCoreAsync(default);
|
|
|
|
Assert.Equal(0, result.MarkersDeleted);
|
|
Assert.True(fixture.Repo.Markers.ContainsKey(TenantId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task BlobDeleteFails_KeepsMarkerForNextRun()
|
|
{
|
|
// Rev-9 hygiene: blob deleted BEFORE marker. If blob delete throws (transient storage
|
|
// glitch), marker stays so the next 2h-run tries again. Otherwise an orphan blob lives
|
|
// forever in offboarding-state.
|
|
var fixture = new Fixture();
|
|
fixture.Repo.Markers[TenantId] = NewMarker(status: "Completed",
|
|
completedAt: fixture.FixedNow - TimeSpan.FromMinutes(20));
|
|
fixture.Expectations.Seed(NewExpectations());
|
|
fixture.Expectations.ThrowOnDelete = true;
|
|
|
|
var result = await fixture.Sut.RunCoreAsync(default);
|
|
|
|
Assert.Equal(0, result.MarkersDeleted);
|
|
Assert.Equal(1, result.BlobDeleteRetries);
|
|
Assert.True(fixture.Repo.Markers.ContainsKey(TenantId),
|
|
"Marker stays so the next run retries the blob delete");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DryRunWithMixedFleet_HandlesEachMarkerIndependently()
|
|
{
|
|
var fixture = new Fixture();
|
|
fixture.Repo.Markers["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"] = NewMarker(
|
|
tenantId: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
status: "Completed", completedAt: fixture.FixedNow - TimeSpan.FromMinutes(30));
|
|
fixture.Repo.Markers["bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"] = NewMarker(
|
|
tenantId: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
|
status: "Completed", completedAt: fixture.FixedNow - TimeSpan.FromMinutes(2));
|
|
fixture.Repo.Markers["cccccccc-cccc-cccc-cccc-cccccccccccc"] = NewMarker(
|
|
tenantId: "cccccccc-cccc-cccc-cccc-cccccccccccc",
|
|
status: "Failed", failedAt: fixture.FixedNow - TimeSpan.FromHours(2),
|
|
failedPhase: "drain_timeout");
|
|
fixture.Repo.Markers["dddddddd-dddd-dddd-dddd-dddddddddddd"] = NewMarker(
|
|
tenantId: "dddddddd-dddd-dddd-dddd-dddddddddddd",
|
|
status: "InProgress");
|
|
|
|
var result = await fixture.Sut.RunCoreAsync(default);
|
|
|
|
Assert.Equal(4, result.Scanned);
|
|
Assert.Equal(1, result.MarkersDeleted); // only the 30-min-old Completed one
|
|
Assert.Equal(1, result.FailedMarkersSeen);
|
|
Assert.False(fixture.Repo.Markers.ContainsKey("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"));
|
|
Assert.True(fixture.Repo.Markers.ContainsKey("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"));
|
|
Assert.True(fixture.Repo.Markers.ContainsKey("cccccccc-cccc-cccc-cccc-cccccccccccc"));
|
|
Assert.True(fixture.Repo.Markers.ContainsKey("dddddddd-dddd-dddd-dddd-dddddddddddd"));
|
|
}
|
|
|
|
// ── TenantConfiguration defense-in-depth sweep (PR3.B-revised Finding 2) ──────
|
|
//
|
|
// Phase 2.F-final in the handler is fail-soft. If it failed (worker crash, transient
|
|
// storage), the TenantConfiguration row would carry Disabled=true and block the
|
|
// tenant from self-service re-onboarding indefinitely. This sweep is the second
|
|
// pass: when MarkerCleanup spots a Completed marker eligible for removal, it tries
|
|
// to delete TenantConfiguration FIRST. If the sweep fails, the marker stays so the
|
|
// next 2h run retries.
|
|
|
|
[Fact]
|
|
public async Task CompletedMarker_SweepsLingeringTenantConfig_BeforeDeletingMarker()
|
|
{
|
|
var fixture = new Fixture();
|
|
fixture.Repo.Markers[TenantId] = NewMarker(
|
|
status: "Completed",
|
|
completedAt: fixture.FixedNow - TimeSpan.FromMinutes(20));
|
|
// Simulate "Phase 2.F-final failed in the handler" → the row is still the
|
|
// offboarding tombstone (Disabled=true + magic reason). Sweep should wipe it.
|
|
fixture.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
|
.ReturnsAsync(BuildTombstoneConfig(TenantId));
|
|
fixture.SafeWipe.QueuedReturns.Enqueue(1);
|
|
|
|
var result = await fixture.Sut.RunCoreAsync(default);
|
|
|
|
Assert.Equal(1, result.MarkersDeleted);
|
|
Assert.Equal(1, result.TenantConfigsSwept);
|
|
Assert.Equal(0, result.TenantConfigsSpared);
|
|
Assert.Equal(0, result.TenantConfigSweepRetries);
|
|
|
|
// The sweep ran with the right tables + tenant id: the tombstone itself, plus the
|
|
// tenant's config-backup partition (a save against the lingering tombstone may have
|
|
// re-created snapshots after the cascade's bulk wipe).
|
|
Assert.Equal(2, fixture.SafeWipe.WipeCalls.Count);
|
|
Assert.Equal(Constants.TableNames.TenantConfiguration, fixture.SafeWipe.WipeCalls[0].Table);
|
|
Assert.Equal(TenantId, fixture.SafeWipe.WipeCalls[0].TenantId);
|
|
Assert.Equal(Constants.TableNames.ConfigurationBackups, fixture.SafeWipe.WipeCalls[1].Table);
|
|
Assert.Equal(TenantId, fixture.SafeWipe.WipeCalls[1].TenantId);
|
|
Assert.False(fixture.Repo.Markers.ContainsKey(TenantId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompletedMarker_TenantConfigAlreadyGone_SkipsSweep_DeletesMarker()
|
|
{
|
|
// Happy path: Phase 2.F-final succeeded in the handler. GetTenantConfigurationAsync
|
|
// returns null (default fixture stub) → no row to wipe.
|
|
var fixture = new Fixture();
|
|
fixture.Repo.Markers[TenantId] = NewMarker(
|
|
status: "Completed",
|
|
completedAt: fixture.FixedNow - TimeSpan.FromMinutes(20));
|
|
|
|
var result = await fixture.Sut.RunCoreAsync(default);
|
|
|
|
Assert.Equal(1, result.MarkersDeleted);
|
|
Assert.Equal(0, result.TenantConfigsSwept);
|
|
Assert.Equal(0, result.TenantConfigsSpared);
|
|
// No SafeWipe call when the row is already gone (we don't waste a transaction).
|
|
Assert.Empty(fixture.SafeWipe.WipeCalls);
|
|
Assert.False(fixture.Repo.Markers.ContainsKey(TenantId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompletedMarker_UserReonboarded_SparesFreshConfig_AndStillDeletesMarker()
|
|
{
|
|
// The crucial Codex Finding 1 scenario: between Phase 2.F-final and MarkerCleanup,
|
|
// the user self-service-re-onboarded. The TenantConfiguration row now exists with
|
|
// Disabled=false (or any non-offboarding DisabledReason). Blind sweep would
|
|
// destroy their fresh tenant. The conditional probe must skip the wipe.
|
|
var fixture = new Fixture();
|
|
fixture.Repo.Markers[TenantId] = NewMarker(
|
|
status: "Completed",
|
|
completedAt: fixture.FixedNow - TimeSpan.FromMinutes(20));
|
|
|
|
var freshConfig = TenantConfiguration.CreateDefault(TenantId);
|
|
freshConfig.Disabled = false;
|
|
freshConfig.UpdatedBy = "bob@contoso.com";
|
|
fixture.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
|
.ReturnsAsync(freshConfig);
|
|
|
|
var result = await fixture.Sut.RunCoreAsync(default);
|
|
|
|
Assert.Equal(1, result.MarkersDeleted);
|
|
Assert.Equal(0, result.TenantConfigsSwept);
|
|
Assert.Equal(1, result.TenantConfigsSpared);
|
|
// Critically: SafeWipe was NEVER called. The fresh config is preserved.
|
|
Assert.Empty(fixture.SafeWipe.WipeCalls);
|
|
// The Completed marker still gets cleaned up — its work is done.
|
|
Assert.False(fixture.Repo.Markers.ContainsKey(TenantId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompletedMarker_UserReonboarded_AndSuspendedThemselves_StillSpared()
|
|
{
|
|
// Defense-in-depth check: a re-onboarded tenant that later self-suspended would
|
|
// have Disabled=true but with a different DisabledReason (e.g. "Self-suspended").
|
|
// The magic-string match must distinguish — only "Offboarding in progress" triggers
|
|
// the wipe.
|
|
var fixture = new Fixture();
|
|
fixture.Repo.Markers[TenantId] = NewMarker(
|
|
status: "Completed",
|
|
completedAt: fixture.FixedNow - TimeSpan.FromMinutes(20));
|
|
|
|
var reonboardedThenSuspended = TenantConfiguration.CreateDefault(TenantId);
|
|
reonboardedThenSuspended.Disabled = true;
|
|
reonboardedThenSuspended.DisabledReason = "Self-suspended";
|
|
fixture.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
|
.ReturnsAsync(reonboardedThenSuspended);
|
|
|
|
var result = await fixture.Sut.RunCoreAsync(default);
|
|
|
|
Assert.Equal(0, result.TenantConfigsSwept);
|
|
Assert.Equal(1, result.TenantConfigsSpared);
|
|
Assert.Empty(fixture.SafeWipe.WipeCalls);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompletedMarker_TenantConfigProbeThrows_KeepsMarkerForNextRetry()
|
|
{
|
|
var fixture = new Fixture();
|
|
fixture.Repo.Markers[TenantId] = NewMarker(
|
|
status: "Completed",
|
|
completedAt: fixture.FixedNow - TimeSpan.FromMinutes(20));
|
|
fixture.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
|
.ThrowsAsync(new InvalidOperationException("storage down"));
|
|
|
|
var result = await fixture.Sut.RunCoreAsync(default);
|
|
|
|
Assert.Equal(0, result.MarkersDeleted);
|
|
Assert.Equal(1, result.TenantConfigSweepRetries);
|
|
Assert.True(fixture.Repo.Markers.ContainsKey(TenantId),
|
|
"Marker must stay alive when the TenantConfiguration probe fails so the next run can retry");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompletedMarker_TenantConfigSweepThrows_KeepsMarkerForNextRetry()
|
|
{
|
|
var fixture = new Fixture();
|
|
fixture.Repo.Markers[TenantId] = NewMarker(
|
|
status: "Completed",
|
|
completedAt: fixture.FixedNow - TimeSpan.FromMinutes(20));
|
|
fixture.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(TenantId))
|
|
.ReturnsAsync(BuildTombstoneConfig(TenantId));
|
|
fixture.SafeWipe.ThrowOnNextWipe = new InvalidOperationException("storage down");
|
|
|
|
var result = await fixture.Sut.RunCoreAsync(default);
|
|
|
|
Assert.Equal(0, result.MarkersDeleted);
|
|
Assert.Equal(1, result.TenantConfigSweepRetries);
|
|
Assert.True(fixture.Repo.Markers.ContainsKey(TenantId),
|
|
"Marker must stay alive when the TenantConfig sweep fails so the next run can retry");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CompletedMarker_BlobDeleteFails_TenantConfigProbeIsNotEvenAttempted()
|
|
{
|
|
var fixture = new Fixture();
|
|
fixture.Repo.Markers[TenantId] = NewMarker(
|
|
status: "Completed",
|
|
completedAt: fixture.FixedNow - TimeSpan.FromMinutes(20));
|
|
fixture.Expectations.ThrowOnDelete = true;
|
|
|
|
var result = await fixture.Sut.RunCoreAsync(default);
|
|
|
|
Assert.Equal(1, result.BlobDeleteRetries);
|
|
Assert.Empty(fixture.SafeWipe.WipeCalls);
|
|
Assert.True(fixture.Repo.Markers.ContainsKey(TenantId));
|
|
// Probe shouldn't have been attempted either — blob is the first gate.
|
|
fixture.ConfigRepo.Verify(r => r.GetTenantConfigurationAsync(It.IsAny<string>()), Times.Never);
|
|
}
|
|
|
|
private static TenantConfiguration BuildTombstoneConfig(string tenantId)
|
|
{
|
|
var config = TenantConfiguration.CreateDefault(tenantId);
|
|
config.Disabled = true;
|
|
config.DisabledReason = "Offboarding in progress";
|
|
return config;
|
|
}
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
private static OffboardingMarkerEntry NewMarker(
|
|
string? tenantId = null,
|
|
string status = "Completed",
|
|
DateTime? completedAt = null,
|
|
DateTime? failedAt = null,
|
|
string? failedPhase = null)
|
|
{
|
|
var tid = tenantId ?? TenantId;
|
|
return new OffboardingMarkerEntry
|
|
{
|
|
PartitionKey = Constants.OffboardingPartitionKeys.Marker,
|
|
RowKey = tid,
|
|
TenantId = tid,
|
|
OffboardingHistoryRowKey = $"20260518091523123_{tid}",
|
|
InitiatedAt = DateTime.UtcNow.AddHours(-1),
|
|
InitiatedBy = "alice@contoso.invalid",
|
|
Status = status,
|
|
CompletedAt = completedAt,
|
|
FailedAt = failedAt,
|
|
FailedPhase = failedPhase,
|
|
};
|
|
}
|
|
|
|
private static OffboardingExpectations NewExpectations() => new()
|
|
{
|
|
SchemaVersion = 1,
|
|
TenantId = TenantId,
|
|
HistoryRowKey = HistoryRowKey,
|
|
CreatedAt = DateTime.UtcNow.AddHours(-1),
|
|
EnumerationCompleted = true,
|
|
EnumeratedSessionCount = 0,
|
|
};
|
|
|
|
private sealed class Fixture
|
|
{
|
|
public DateTime FixedNow { get; } = new DateTime(2026, 5, 18, 12, 0, 0, DateTimeKind.Utc);
|
|
public FakeOffboardingAuditRepository Repo { get; } = new();
|
|
public FakeOffboardingExpectationsStore Expectations { get; } = new();
|
|
public RecordingSafeWipeService SafeWipe { get; } = new();
|
|
public Mock<IConfigRepository> ConfigRepo { get; } = new();
|
|
public OffboardingMarkerCleanupFunction Sut { get; }
|
|
|
|
public Fixture()
|
|
{
|
|
var timeProvider = new FixedTimeProvider(FixedNow);
|
|
// Default: TenantConfig row not found (the normal happy path — Phase 2.F-final
|
|
// successfully deleted it; nothing for the sweep to do). Individual tests
|
|
// override this to seed a lingering tombstone or a re-onboarded fresh config.
|
|
ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
|
|
.ReturnsAsync((TenantConfiguration?)null);
|
|
|
|
Sut = new OffboardingMarkerCleanupFunction(
|
|
Repo, Expectations, SafeWipe, ConfigRepo.Object,
|
|
NullLogger<OffboardingMarkerCleanupFunction>.Instance,
|
|
timeProvider);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Mock <see cref="AutopilotMonitor.Functions.Services.Offboarding.SafeWipeService"/> that
|
|
/// records which (table, tenantId) pairs were swept and how many rows it claims to have
|
|
/// deleted. Default behaviour returns 0 (row already gone — the normal happy path).
|
|
/// Tests can opt in to "0 means a TenantConfiguration row was found and wiped" via
|
|
/// <see cref="QueuedReturns"/>.
|
|
/// </summary>
|
|
private sealed class RecordingSafeWipeService : AutopilotMonitor.Functions.Services.Offboarding.SafeWipeService
|
|
{
|
|
public readonly List<(string Table, string TenantId)> WipeCalls = new();
|
|
public readonly Queue<int> QueuedReturns = new();
|
|
public Exception? ThrowOnNextWipe { get; set; }
|
|
|
|
public RecordingSafeWipeService()
|
|
: base(
|
|
new AutopilotMonitor.Functions.Services.TableStorageService(
|
|
Moq.Mock.Of<Azure.Data.Tables.TableServiceClient>(),
|
|
NullLogger<AutopilotMonitor.Functions.Services.TableStorageService>.Instance),
|
|
new AutopilotMonitor.Functions.Services.BlobStorageService(
|
|
new Azure.Storage.Blobs.BlobServiceClient("UseDevelopmentStorage=true"),
|
|
NullLogger<AutopilotMonitor.Functions.Services.BlobStorageService>.Instance,
|
|
usesManagedIdentity: false),
|
|
NullLogger<AutopilotMonitor.Functions.Services.Offboarding.SafeWipeService>.Instance)
|
|
{
|
|
}
|
|
|
|
public override Task<int> WipeByExactPartitionAsync(string tableName, string normalizedTenantId, CancellationToken ct = default)
|
|
{
|
|
WipeCalls.Add((tableName, normalizedTenantId));
|
|
if (ThrowOnNextWipe is { } ex)
|
|
{
|
|
ThrowOnNextWipe = null;
|
|
throw ex;
|
|
}
|
|
return Task.FromResult(QueuedReturns.Count > 0 ? QueuedReturns.Dequeue() : 0);
|
|
}
|
|
}
|
|
|
|
private sealed class FixedTimeProvider : TimeProvider
|
|
{
|
|
private readonly DateTimeOffset _now;
|
|
public FixedTimeProvider(DateTime utcNow) { _now = new DateTimeOffset(utcNow, TimeSpan.Zero); }
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
}
|
|
}
|