diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/BootstrapAuthDisabledTenantTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/BootstrapAuthDisabledTenantTests.cs
new file mode 100644
index 00000000..f2359842
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/BootstrapAuthDisabledTenantTests.cs
@@ -0,0 +1,111 @@
+using System.Net;
+using AutopilotMonitor.Functions.Security;
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Models;
+using Microsoft.Azure.Functions.Worker.Http;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging;
+using Moq;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Plan v2 §5 (bootstrap-path interaction): when a tenant is mid-offboarding,
+/// Phase 1 flips TenantConfiguration.Disabled = true and Phase 2 starts after
+/// the cache-drain barrier. The auth pipeline (cert + bootstrap-token) must reject
+/// requests against a Disabled tenant — otherwise a fresh OOBE device with a bootstrap
+/// token could keep writing into a tenant about to be wiped.
+///
+/// The §0 Disabled-gate in runs
+/// BEFORE the §0.5 bootstrap-token branch, so this property is enforced by the order
+/// of checks. These tests pin that ordering so future refactors cannot accidentally
+/// move the bootstrap branch above the Disabled gate.
+///
+///
+public sealed class BootstrapAuthDisabledTenantTests
+{
+ private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
+ private const string BootstrapToken = "11111111-2222-3333-4444-555555555555";
+
+ private static SecurityValidator BuildValidator(TenantConfiguration config)
+ {
+ var configRepo = Mock.Of();
+ var cache = new MemoryCache(new MemoryCacheOptions());
+
+ var configServiceMock = new Mock(
+ configRepo, Mock.Of>(), cache)
+ { CallBase = false };
+ configServiceMock
+ .Setup(x => x.TryGetConfigurationAsync(It.IsAny()))
+ .ReturnsAsync((config, true));
+
+ return new SecurityValidator(
+ configService: configServiceMock.Object,
+ rateLimitService: null!,
+ autopilotDeviceValidator: null!,
+ corporateIdentifierValidator: null!,
+ logger: Mock.Of(),
+ bootstrapSessionService: null,
+ deviceAssociationValidator: null);
+ }
+
+ private static HttpRequestData BuildRequestWithBootstrapToken()
+ {
+ var contextMock = new Mock();
+ var reqMock = new Mock(contextMock.Object);
+
+ var headers = new Microsoft.Azure.Functions.Worker.Http.HttpHeadersCollection
+ {
+ { "X-Bootstrap-Token", BootstrapToken },
+ };
+ reqMock.SetupGet(r => r.Headers).Returns(headers);
+
+ return reqMock.Object;
+ }
+
+ [Fact]
+ public async Task BootstrapToken_RejectedWhenTenantDisabled()
+ {
+ var disabled = TenantConfiguration.CreateDefault(TenantId);
+ disabled.Disabled = true;
+ disabled.DisabledReason = "Offboarding in progress";
+ disabled.DisabledUntil = null;
+
+ var sut = BuildValidator(disabled);
+
+ var result = await sut.ValidateRequestAsync(BuildRequestWithBootstrapToken(), TenantId);
+
+ // Must be the §0 Disabled-gate, not the §0.5 bootstrap branch. The bootstrap
+ // branch lives AFTER the Disabled check; if anyone ever reorders these, the
+ // assertion below catches it (Disabled returns "Tenant is suspended" with the
+ // configured reason; bootstrap-branch failures have different ErrorMessages).
+ Assert.False(result.IsValid);
+ Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
+ Assert.Equal("Tenant is suspended", result.ErrorMessage);
+ Assert.Equal("Offboarding in progress", result.Details);
+ }
+
+ [Fact]
+ public async Task BootstrapToken_AcceptedWhenTenantNotDisabled_StillEvaluatesBootstrapBranch()
+ {
+ // Sanity: a non-disabled tenant DOES reach the bootstrap branch (which will then
+ // run its own validation against _bootstrapSessionService). With _bootstrapSessionService
+ // null in this test, the bootstrap branch falls through to the cert path — proving
+ // that the §0 gate did NOT block.
+ var enabled = TenantConfiguration.CreateDefault(TenantId);
+ enabled.Disabled = false;
+ enabled.ValidateAutopilotDevice = false;
+ enabled.ValidateCorporateIdentifier = false;
+ enabled.AllowInsecureAgentRequests = false;
+
+ var sut = BuildValidator(enabled);
+
+ var result = await sut.ValidateRequestAsync(BuildRequestWithBootstrapToken(), TenantId);
+
+ // Falls through to the "device validation is required" gate at §1 (not the §0 Disabled gate).
+ Assert.False(result.IsValid);
+ Assert.Equal(HttpStatusCode.Forbidden, result.StatusCode);
+ Assert.Equal("Device validation is required", result.ErrorMessage);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DeletionProgressDrainProbeTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DeletionProgressDrainProbeTests.cs
new file mode 100644
index 00000000..bd6893b0
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/DeletionProgressDrainProbeTests.cs
@@ -0,0 +1,109 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure;
+using Azure.Storage.Blobs;
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Functions.Services.Offboarding;
+using AutopilotMonitor.Shared.Models.Deletion;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Plan §7.4 step 5 + §10.1: drain predicate is true only when the progress blob has
+/// CompletedAt != null AND TombstoneStarted == true. 404 is "not yet done", not an error.
+///
+public class DeletionProgressDrainProbeTests
+{
+ private const string TenantId = "44444444-4444-4444-4444-444444444444";
+ private const string SessionId = "55555555-5555-5555-5555-555555555555";
+ private const string ManifestId = "manifest-1";
+
+ [Fact]
+ public async Task IsCompleted_TrueOnlyWhen_CompletedAtSet_AndTombstoneStarted()
+ {
+ var sut = new DeletionProgressDrainProbe(
+ new FakeBlob(new DeletionProgress
+ {
+ CompletedAt = DateTime.UtcNow,
+ TombstoneStarted = true,
+ }),
+ NullLogger.Instance);
+
+ Assert.True(await sut.IsCascadeCompletedAsync(TenantId, SessionId, ManifestId));
+ }
+
+ [Fact]
+ public async Task IsCompleted_FalseWhen_CompletedAtNull()
+ {
+ var sut = new DeletionProgressDrainProbe(
+ new FakeBlob(new DeletionProgress { TombstoneStarted = true, CompletedAt = null }),
+ NullLogger.Instance);
+
+ Assert.False(await sut.IsCascadeCompletedAsync(TenantId, SessionId, ManifestId));
+ }
+
+ [Fact]
+ public async Task IsCompleted_FalseWhen_TombstoneNotStarted_RevWalkthrough()
+ {
+ // If CompletedAt got somehow set without TombstoneStarted, the cascade did not
+ // finish the FINAL step — drain must say "no" to defend against truncated progress.
+ var sut = new DeletionProgressDrainProbe(
+ new FakeBlob(new DeletionProgress { CompletedAt = DateTime.UtcNow, TombstoneStarted = false }),
+ NullLogger.Instance);
+
+ Assert.False(await sut.IsCascadeCompletedAsync(TenantId, SessionId, ManifestId));
+ }
+
+ [Fact]
+ public async Task IsCompleted_FalseOn404_NotAnError()
+ {
+ var sut = new DeletionProgressDrainProbe(
+ new FakeBlob(throwOnDownload: new RequestFailedException(404, "NotFound", "BlobNotFound", null)),
+ NullLogger.Instance);
+
+ Assert.False(await sut.IsCascadeCompletedAsync(TenantId, SessionId, ManifestId));
+ }
+
+ [Fact]
+ public async Task IsCompleted_PropagatesNon404StorageError()
+ {
+ // 503 / 500 are real transients the worker must surface so the queue retries.
+ var sut = new DeletionProgressDrainProbe(
+ new FakeBlob(throwOnDownload: new RequestFailedException(503, "ServiceUnavailable", "ServerBusy", null)),
+ NullLogger.Instance);
+
+ await Assert.ThrowsAsync(
+ () => sut.IsCascadeCompletedAsync(TenantId, SessionId, ManifestId));
+ }
+
+ // ── Fake BlobStorageService that overrides only the download seam we need ──
+
+ private sealed class FakeBlob : BlobStorageService
+ {
+ private readonly DeletionProgress? _progress;
+ private readonly RequestFailedException? _throw;
+
+ public FakeBlob(DeletionProgress progress)
+ : base(new BlobServiceClient("UseDevelopmentStorage=true"), NullLogger.Instance, usesManagedIdentity: false)
+ {
+ _progress = progress;
+ }
+
+ public FakeBlob(RequestFailedException throwOnDownload)
+ : base(new BlobServiceClient("UseDevelopmentStorage=true"), NullLogger.Instance, usesManagedIdentity: false)
+ {
+ _throw = throwOnDownload;
+ }
+
+ public override Task<(DeletionProgress Progress, string ETag)> DownloadDeletionProgressAsync(
+ string tenantId, string sessionId, string manifestId, CancellationToken cancellationToken = default)
+ {
+ if (_throw != null) throw _throw;
+ return Task.FromResult((_progress!, "\"0xFAKE\""));
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/FakeCustomsArchiveRepository.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/FakeCustomsArchiveRepository.cs
new file mode 100644
index 00000000..75382fce
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/FakeCustomsArchiveRepository.cs
@@ -0,0 +1,105 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Models.Offboarding;
+
+namespace AutopilotMonitor.Functions.Tests.Offboarding;
+
+///
+/// In-memory for PR3.B handler + endpoint tests.
+/// Idempotent upsert keyed by (PK, RK); supports the four query shapes the admin API uses.
+///
+internal sealed class FakeCustomsArchiveRepository : ITenantCustomsArchiveRepository
+{
+ private readonly Dictionary _store
+ = new(System.StringComparer.Ordinal);
+
+ public IReadOnlyDictionary Store => _store;
+
+ private static string Key(string pk, string rk) => pk + "|" + rk;
+
+ public Task UpsertAsync(TenantOffboardingCustomsArchiveEntry entry, CancellationToken ct = default)
+ {
+ _store[Key(entry.PartitionKey, entry.RowKey)] = Clone(entry);
+ return Task.CompletedTask;
+ }
+
+ public Task CountByRunAndTableAsync(string normalizedTenantId, string historyRowKey, string originalTable, CancellationToken ct = default)
+ {
+ var pk = $"{normalizedTenantId}_{historyRowKey}";
+ var count = _store.Values.Count(e =>
+ e.PartitionKey == pk && e.OriginalTable == originalTable);
+ return Task.FromResult(count);
+ }
+
+ public async IAsyncEnumerable QueryByRunAsync(
+ string normalizedTenantId, string historyRowKey,
+ [EnumeratorCancellation] CancellationToken ct = default)
+ {
+ var pk = $"{normalizedTenantId}_{historyRowKey}";
+ foreach (var entry in _store.Values.Where(e => e.PartitionKey == pk))
+ {
+ yield return Clone(entry);
+ }
+ await Task.CompletedTask;
+ }
+
+ public async IAsyncEnumerable QueryByTenantAsync(
+ string normalizedTenantId, [EnumeratorCancellation] CancellationToken ct = default)
+ {
+ var prefix = normalizedTenantId + "_";
+ foreach (var entry in _store.Values.Where(e => e.PartitionKey.StartsWith(prefix)))
+ {
+ yield return Clone(entry);
+ }
+ await Task.CompletedTask;
+ }
+
+ public async IAsyncEnumerable QueryAllAsync(
+ [EnumeratorCancellation] CancellationToken ct = default)
+ {
+ foreach (var entry in _store.Values)
+ {
+ yield return Clone(entry);
+ }
+ await Task.CompletedTask;
+ }
+
+ public Task TryGetEntryAsync(
+ string partitionKey, string rowKey, CancellationToken ct = default)
+ {
+ _store.TryGetValue(Key(partitionKey, rowKey), out var entry);
+ return Task.FromResult(entry == null ? null : Clone(entry));
+ }
+
+ public Task DeleteEntryAsync(string partitionKey, string rowKey, CancellationToken ct = default)
+ {
+ _store.Remove(Key(partitionKey, rowKey));
+ return Task.CompletedTask;
+ }
+
+ public Task DeleteRunAsync(string normalizedTenantId, string historyRowKey, CancellationToken ct = default)
+ {
+ var pk = $"{normalizedTenantId}_{historyRowKey}";
+ var toDelete = _store.Keys.Where(k => k.StartsWith(pk + "|")).ToList();
+ foreach (var k in toDelete) _store.Remove(k);
+ return Task.FromResult(toDelete.Count);
+ }
+
+ private static TenantOffboardingCustomsArchiveEntry Clone(TenantOffboardingCustomsArchiveEntry e) => new()
+ {
+ PartitionKey = e.PartitionKey,
+ RowKey = e.RowKey,
+ TenantId = e.TenantId,
+ OriginalTable = e.OriginalTable,
+ OriginalPartitionKey = e.OriginalPartitionKey,
+ OriginalRowKey = e.OriginalRowKey,
+ EntityJson = e.EntityJson,
+ HistoryRowKey = e.HistoryRowKey,
+ ArchivedAt = e.ArchivedAt,
+ ArchivedBy = e.ArchivedBy,
+ };
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/FakeOffboardingAuditRepository.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/FakeOffboardingAuditRepository.cs
new file mode 100644
index 00000000..9b5f04f4
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/FakeOffboardingAuditRepository.cs
@@ -0,0 +1,155 @@
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Models.Offboarding;
+using Azure;
+
+namespace AutopilotMonitor.Functions.Tests.Offboarding;
+
+///
+/// In-memory for handler/cleanup tests. Records the
+/// latest write per (PK, RK) so the test can assert lifecycle transitions, and mirrors the
+/// production ETag-CAS contract on the pointer (412 on mismatched ETag).
+///
+internal sealed class FakeOffboardingAuditRepository : IOffboardingAuditRepository
+{
+ public Dictionary Markers { get; } = new();
+ public Dictionary History { get; } = new();
+ public Dictionary Pointers { get; } = new();
+ public List MarkerWrites { get; } = new();
+ public List HistoryWrites { get; } = new();
+
+ /// When set, the next call to throws — used by
+ /// poison-path tests to simulate a transient storage failure inside the Failed-state
+ /// transition so the Worker's reorder invariant can be exercised.
+ public Exception? ThrowOnNextHistoryUpsert { get; set; }
+
+ private int _etagCounter;
+ private string NextEtag() => $"\"0xFAKE_{++_etagCounter}\"";
+
+ public Task TryGetMarkerAsync(string normalizedTenantId, CancellationToken ct = default)
+ => Task.FromResult(Markers.TryGetValue(normalizedTenantId, out var m) ? Clone(m) : null);
+
+ public Task InsertMarkerAsync(OffboardingMarkerEntry marker, CancellationToken ct = default)
+ {
+ if (Markers.ContainsKey(marker.RowKey))
+ throw new RequestFailedException(409, "Conflict", "EntityAlreadyExists", null);
+ Markers[marker.RowKey] = Clone(marker);
+ MarkerWrites.Add(marker.Status);
+ return Task.CompletedTask;
+ }
+
+ public Task UpsertMarkerAsync(OffboardingMarkerEntry marker, CancellationToken ct = default)
+ {
+ Markers[marker.RowKey] = Clone(marker);
+ MarkerWrites.Add(marker.Status);
+ return Task.CompletedTask;
+ }
+
+ public Task DeleteMarkerAsync(string normalizedTenantId, CancellationToken ct = default)
+ {
+ Markers.Remove(normalizedTenantId);
+ return Task.CompletedTask;
+ }
+
+ public async IAsyncEnumerable QueryMarkersAsync(
+ [EnumeratorCancellation] CancellationToken ct = default)
+ {
+ foreach (var marker in Markers.Values)
+ {
+ yield return Clone(marker);
+ }
+ await Task.CompletedTask;
+ }
+
+ public Task InsertHistoryAsync(OffboardingHistoryEntry history, CancellationToken ct = default)
+ {
+ if (History.ContainsKey(history.RowKey))
+ throw new RequestFailedException(409, "Conflict", "EntityAlreadyExists", null);
+ History[history.RowKey] = Clone(history);
+ HistoryWrites.Add(history.Status);
+ return Task.CompletedTask;
+ }
+
+ public Task TryGetHistoryAsync(string historyRowKey, CancellationToken ct = default)
+ => Task.FromResult(History.TryGetValue(historyRowKey, out var h) ? Clone(h) : null);
+
+ public Task UpsertHistoryAsync(OffboardingHistoryEntry history, CancellationToken ct = default)
+ {
+ if (ThrowOnNextHistoryUpsert is { } ex)
+ {
+ ThrowOnNextHistoryUpsert = null;
+ throw ex;
+ }
+ History[history.RowKey] = Clone(history);
+ HistoryWrites.Add(history.Status);
+ return Task.CompletedTask;
+ }
+
+ public Task<(OffboardingByTenantPointer? Pointer, string? ETag)> TryGetByTenantPointerAsync(
+ string normalizedTenantId, CancellationToken ct = default)
+ {
+ if (Pointers.TryGetValue(normalizedTenantId, out var entry))
+ {
+ return Task.FromResult<(OffboardingByTenantPointer?, string?)>((Clone(entry.Pointer), entry.ETag));
+ }
+ return Task.FromResult<(OffboardingByTenantPointer?, string?)>((null, null));
+ }
+
+ public Task InsertByTenantPointerAsync(OffboardingByTenantPointer pointer, CancellationToken ct = default)
+ {
+ if (Pointers.ContainsKey(pointer.RowKey))
+ throw new RequestFailedException(409, "Conflict", "EntityAlreadyExists", null);
+ Pointers[pointer.RowKey] = (Clone(pointer), NextEtag());
+ return Task.CompletedTask;
+ }
+
+ public Task UpdateByTenantPointerWithEtagAsync(
+ OffboardingByTenantPointer pointer, string ifMatchEtag, CancellationToken ct = default)
+ {
+ if (!Pointers.TryGetValue(pointer.RowKey, out var entry))
+ throw new RequestFailedException(404, "NotFound", "ResourceNotFound", null);
+ if (entry.ETag != ifMatchEtag)
+ throw new RequestFailedException(412, "ConditionNotMet", "UpdateConditionNotSatisfied", null);
+ Pointers[pointer.RowKey] = (Clone(pointer), NextEtag());
+ return Task.CompletedTask;
+ }
+
+ private static OffboardingMarkerEntry Clone(OffboardingMarkerEntry m) => new()
+ {
+ PartitionKey = m.PartitionKey, RowKey = m.RowKey,
+ TenantId = m.TenantId, OffboardingHistoryRowKey = m.OffboardingHistoryRowKey,
+ InitiatedAt = m.InitiatedAt, InitiatedBy = m.InitiatedBy,
+ Status = m.Status, CompletedAt = m.CompletedAt,
+ FailedAt = m.FailedAt, FailedPhase = m.FailedPhase,
+ };
+
+ private static OffboardingHistoryEntry Clone(OffboardingHistoryEntry h) => new()
+ {
+ PartitionKey = h.PartitionKey, RowKey = h.RowKey,
+ TenantId = h.TenantId, DomainName = h.DomainName, InitiatedBy = h.InitiatedBy,
+ OffboardedAt = h.OffboardedAt, CompletedAt = h.CompletedAt,
+ EarliestProcessingAt = h.EarliestProcessingAt,
+ Status = h.Status,
+ DeletedRowCountsJson = h.DeletedRowCountsJson, TotalRowsDeleted = h.TotalRowsDeleted,
+ DeletedBlobCount = h.DeletedBlobCount, CascadeSessionsEnqueued = h.CascadeSessionsEnqueued,
+ ErrorMessage = h.ErrorMessage, RetryCount = h.RetryCount,
+ DrainCompletedAt = h.DrainCompletedAt,
+ EnumerationStartedAt = h.EnumerationStartedAt,
+ EnumerationCompletedBeforeUpload = h.EnumerationCompletedBeforeUpload,
+ CustomGatherRulesArchived = h.CustomGatherRulesArchived,
+ CustomAnalyzeRulesArchived = h.CustomAnalyzeRulesArchived,
+ ImeLogPatternOverridesArchived = h.ImeLogPatternOverridesArchived,
+ ReonboardedAt = h.ReonboardedAt, ReonboardedBy = h.ReonboardedBy,
+ CustomsAutoWipedOnReonboard = h.CustomsAutoWipedOnReonboard,
+ };
+
+ private static OffboardingByTenantPointer Clone(OffboardingByTenantPointer p) => new()
+ {
+ PartitionKey = p.PartitionKey, RowKey = p.RowKey, TenantId = p.TenantId,
+ LatestHistoryRowKey = p.LatestHistoryRowKey, LatestStatus = p.LatestStatus,
+ LatestUpdatedAt = p.LatestUpdatedAt, OffboardCount = p.OffboardCount,
+ };
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/FakeOffboardingExpectationsStore.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/FakeOffboardingExpectationsStore.cs
new file mode 100644
index 00000000..c3dca272
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/FakeOffboardingExpectationsStore.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Functions.Services.Offboarding;
+using AutopilotMonitor.Shared.Models.Offboarding;
+using Azure;
+
+namespace AutopilotMonitor.Functions.Tests.Offboarding;
+
+///
+/// In-memory with the production If-None-Match=*
+/// and If-Match ETag-CAS semantics. Tracks delete attempts so tests can assert idempotency.
+///
+internal sealed class FakeOffboardingExpectationsStore : IOffboardingExpectationsStore
+{
+ private readonly Dictionary _blobs = new();
+ private int _etagCounter;
+
+ public int DeleteCallCount { get; private set; }
+ public bool ThrowOnDelete { get; set; }
+
+ public Task TryUploadInitialAsync(OffboardingExpectations payload, CancellationToken ct = default)
+ {
+ var key = Key(payload.TenantId, payload.HistoryRowKey);
+ if (_blobs.ContainsKey(key)) return Task.FromResult(false);
+ _blobs[key] = (Clone(payload), NextEtag());
+ return Task.FromResult(true);
+ }
+
+ public Task<(OffboardingExpectations? Payload, string? ETag)> TryDownloadAsync(
+ string tenantId, string historyRowKey, CancellationToken ct = default)
+ {
+ var key = Key(tenantId, historyRowKey);
+ if (!_blobs.TryGetValue(key, out var entry))
+ return Task.FromResult<(OffboardingExpectations?, string?)>((null, null));
+ return Task.FromResult<(OffboardingExpectations?, string?)>((Clone(entry.Payload), entry.ETag));
+ }
+
+ public Task UpdateWithEtagCasAsync(OffboardingExpectations payload, string ifMatchEtag, CancellationToken ct = default)
+ {
+ var key = Key(payload.TenantId, payload.HistoryRowKey);
+ if (!_blobs.TryGetValue(key, out var entry))
+ throw new RequestFailedException(404, "NotFound", "BlobNotFound", null);
+ if (entry.ETag != ifMatchEtag)
+ throw new RequestFailedException(412, "ConditionNotMet", "ConditionNotMet", null);
+ var newEtag = NextEtag();
+ _blobs[key] = (Clone(payload), newEtag);
+ return Task.FromResult(newEtag);
+ }
+
+ public Task DeleteAsync(string tenantId, string historyRowKey, CancellationToken ct = default)
+ {
+ DeleteCallCount++;
+ if (ThrowOnDelete) throw new InvalidOperationException("simulated blob-delete failure");
+ _blobs.Remove(Key(tenantId, historyRowKey));
+ return Task.CompletedTask;
+ }
+
+ /// Pre-seed a blob (used to drive the drain probe directly without going via TryUploadInitial).
+ public void Seed(OffboardingExpectations payload)
+ {
+ _blobs[Key(payload.TenantId, payload.HistoryRowKey)] = (Clone(payload), NextEtag());
+ }
+
+ public bool BlobExists(string tenantId, string historyRowKey)
+ => _blobs.ContainsKey(Key(tenantId, historyRowKey));
+
+ private static string Key(string tenantId, string historyRowKey) => $"{tenantId}|{historyRowKey}";
+ private string NextEtag() => $"\"0xFAKE_EXP_{++_etagCounter}\"";
+
+ private static OffboardingExpectations Clone(OffboardingExpectations p)
+ {
+ var clone = new OffboardingExpectations
+ {
+ SchemaVersion = p.SchemaVersion,
+ TenantId = p.TenantId,
+ HistoryRowKey = p.HistoryRowKey,
+ CreatedAt = p.CreatedAt,
+ EnumerationCompleted = p.EnumerationCompleted,
+ EnumeratedSessionCount = p.EnumeratedSessionCount,
+ };
+ foreach (var e in p.Expectations)
+ {
+ clone.Expectations.Add(new OffboardingExpectation
+ {
+ SessionId = e.SessionId,
+ ManifestId = e.ManifestId,
+ Outcome = e.Outcome,
+ RetryCount = e.RetryCount,
+ });
+ }
+ return clone;
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/OffboardingMarkerCleanupFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/OffboardingMarkerCleanupFunctionTests.cs
new file mode 100644
index 00000000..7f7d8892
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/OffboardingMarkerCleanupFunctionTests.cs
@@ -0,0 +1,431 @@
+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;
+
+///
+/// Rev-5-F2 + Rev-9 cleanup contract:
+///
+/// - Completed markers older than 15min are removed — AFTER their Expectations blob.
+/// - Completed markers younger than 15min stay (warm caches could still leak auth).
+/// - Failed markers are NEVER auto-removed — operator action only.
+/// - Initiated / InProgress markers are ignored (offboarding still in flight).
+/// - Blob-delete failure leaves the marker in place so the next run retries.
+///
+///
+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 table + tenant id.
+ Assert.Single(fixture.SafeWipe.WipeCalls);
+ Assert.Equal(Constants.TableNames.TenantConfiguration, fixture.SafeWipe.WipeCalls[0].Table);
+ Assert.Equal(TenantId, fixture.SafeWipe.WipeCalls[0].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()), 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 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()))
+ .ReturnsAsync((TenantConfiguration?)null);
+
+ Sut = new OffboardingMarkerCleanupFunction(
+ Repo, Expectations, SafeWipe, ConfigRepo.Object,
+ NullLogger.Instance,
+ timeProvider);
+ }
+ }
+
+ ///
+ /// Mock 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
+ /// .
+ ///
+ private sealed class RecordingSafeWipeService : AutopilotMonitor.Functions.Services.Offboarding.SafeWipeService
+ {
+ public readonly List<(string Table, string TenantId)> WipeCalls = new();
+ public readonly Queue QueuedReturns = new();
+ public Exception? ThrowOnNextWipe { get; set; }
+
+ public RecordingSafeWipeService()
+ : base(
+ new AutopilotMonitor.Functions.Services.TableStorageService(
+ Moq.Mock.Of(),
+ NullLogger.Instance),
+ new AutopilotMonitor.Functions.Services.BlobStorageService(
+ new Azure.Storage.Blobs.BlobServiceClient("UseDevelopmentStorage=true"),
+ NullLogger.Instance,
+ usesManagedIdentity: false),
+ NullLogger.Instance)
+ {
+ }
+
+ public override Task 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;
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TableTenantCustomsArchiveRepositoryHelperTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TableTenantCustomsArchiveRepositoryHelperTests.cs
new file mode 100644
index 00000000..dc24713e
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TableTenantCustomsArchiveRepositoryHelperTests.cs
@@ -0,0 +1,67 @@
+using AutopilotMonitor.Functions.DataAccess.TableStorage;
+
+namespace AutopilotMonitor.Functions.Tests.Offboarding;
+
+///
+/// PR3.B §3.2 — static-helper tests for the archive RowKey base64url encoding and the
+/// PartitionKey construction. These two functions are the only things in the repository
+/// that have logic worth pinning at unit level; the rest is straight Azure Tables CRUD.
+///
+public sealed class TableTenantCustomsArchiveRepositoryHelperTests
+{
+ [Fact]
+ public void BuildPartitionKey_Concatenates_TenantId_And_HistoryRowKey()
+ {
+ var pk = TableTenantCustomsArchiveRepository.BuildPartitionKey(
+ "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
+ "20260518091523123_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
+ Assert.Equal(
+ "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee_20260518091523123_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
+ pk);
+ }
+
+ [Theory]
+ [InlineData("simple-row-id")]
+ [InlineData("rowkey/with/slashes")]
+ [InlineData("rowkey#with#hash")]
+ [InlineData("rowkey?with?question")]
+ [InlineData("rowkey\\with\\backslash")]
+ [InlineData("a")]
+ [InlineData("ab")]
+ [InlineData("abc")]
+ [InlineData("abcd")]
+ [InlineData("unicode-Ümlaut-and-emoji-✓")]
+ public void BuildRowKey_RoundTripsForbiddenCharacters(string originalRowKey)
+ {
+ var archiveRowKey = TableTenantCustomsArchiveRepository.BuildRowKey("GatherRules", originalRowKey);
+
+ // RowKey itself must NOT contain any Azure-Tables-forbidden characters.
+ Assert.DoesNotContain('#', archiveRowKey);
+ Assert.DoesNotContain('?', archiveRowKey);
+ Assert.DoesNotContain('/', archiveRowKey);
+ Assert.DoesNotContain('\\', archiveRowKey);
+
+ // Must round-trip cleanly.
+ var decoded = TableTenantCustomsArchiveRepository.DecodeOriginalRowKey(archiveRowKey, "GatherRules");
+ Assert.Equal(originalRowKey, decoded);
+ }
+
+ [Fact]
+ public void BuildRowKey_DifferentTablesProduceDifferentKeys()
+ {
+ var gatherKey = TableTenantCustomsArchiveRepository.BuildRowKey("GatherRules", "my-rule");
+ var analyzeKey = TableTenantCustomsArchiveRepository.BuildRowKey("AnalyzeRules", "my-rule");
+
+ Assert.NotEqual(gatherKey, analyzeKey);
+ Assert.StartsWith("GatherRules_", gatherKey);
+ Assert.StartsWith("AnalyzeRules_", analyzeKey);
+ }
+
+ [Fact]
+ public void DecodeOriginalRowKey_RejectsPrefixMismatch()
+ {
+ var rowKey = TableTenantCustomsArchiveRepository.BuildRowKey("GatherRules", "x");
+ Assert.Throws(() =>
+ TableTenantCustomsArchiveRepository.DecodeOriginalRowKey(rowKey, "AnalyzeRules"));
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantCustomsArchiveContractTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantCustomsArchiveContractTests.cs
new file mode 100644
index 00000000..4f346b38
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantCustomsArchiveContractTests.cs
@@ -0,0 +1,185 @@
+using System.Text.Json;
+using AutopilotMonitor.Functions.Services.Offboarding;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Models.Offboarding;
+using Azure.Data.Tables;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace AutopilotMonitor.Functions.Tests.Offboarding;
+
+///
+/// PR3.B §3.4-§3.5 — invariants the offboarding-customs-archive design guarantees:
+/// snapshot serialization, partition-key namespacing per offboarding run, and the
+/// counter-from-archive recomputation that makes the History fields crash-resume safe.
+///
+/// Source-table archive-then-wipe orchestration (3-iteration cap + customs_arrival_race
+/// fail-closed) is a structural property of the loop in
+/// — manual smoketest
+/// on a dev tenant covers that contract; full mock-TableClient integration test is a
+/// followup once we have a TableClient abstraction in the codebase.
+///
+///
+public sealed class TenantCustomsArchiveContractTests
+{
+ private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
+ private const string HistoryRowKey = "20260518091523123_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
+
+ private static TenantOffboardingHandler BuildHandler(ITenantCustomsArchiveRepository archive)
+ {
+ // We only need the handler instance for its internal BuildArchiveEntry helper; that
+ // method does not call into any of the other collaborators, so we can pass null! for
+ // them. Tests exercise pure-method behaviour only.
+ return new TenantOffboardingHandler(
+ auditRepo: null!,
+ enumerator: null!,
+ cascadeEnqueuer: null!,
+ expectations: null!,
+ drainProbe: null!,
+ safeWipe: null!,
+ storage: null!,
+ maintenance: null!,
+ reEnqueuer: null!,
+ opsEvents: null!,
+ customsArchive: archive,
+ logger: NullLogger.Instance);
+ }
+
+ [Fact]
+ public void BuildArchiveEntry_PartitionKey_IsTenantUnderscoreHistoryRowKey()
+ {
+ var handler = BuildHandler(new FakeCustomsArchiveRepository());
+ var source = new TableEntity(TenantId, "rule-id-1") { ["DisplayName"] = "anything" };
+
+ var entry = handler.BuildArchiveEntry(source, TenantId, Constants.TableNames.GatherRules, HistoryRowKey);
+
+ Assert.Equal($"{TenantId}_{HistoryRowKey}", entry.PartitionKey);
+ }
+
+ [Fact]
+ public void BuildArchiveEntry_RowKey_PrefixedWithOriginalTable_AndBase64Encoded()
+ {
+ var handler = BuildHandler(new FakeCustomsArchiveRepository());
+ var source = new TableEntity(TenantId, "rule/with/slash") { ["DisplayName"] = "x" };
+
+ var entry = handler.BuildArchiveEntry(source, TenantId, Constants.TableNames.AnalyzeRules, HistoryRowKey);
+
+ Assert.StartsWith("AnalyzeRules_", entry.RowKey);
+ Assert.DoesNotContain('/', entry.RowKey); // base64url replaces / with _
+ }
+
+ [Fact]
+ public void BuildArchiveEntry_StripsSystemProperties_FromJson()
+ {
+ var handler = BuildHandler(new FakeCustomsArchiveRepository());
+ var source = new TableEntity(TenantId, "rule")
+ {
+ ["DisplayName"] = "MyRule",
+ ["Category"] = "preset",
+ ["Timestamp"] = System.DateTimeOffset.UtcNow,
+ ["odata.etag"] = "\"0x123\"",
+ };
+
+ var entry = handler.BuildArchiveEntry(source, TenantId, Constants.TableNames.GatherRules, HistoryRowKey);
+
+ var parsed = JsonDocument.Parse(entry.EntityJson);
+ Assert.True(parsed.RootElement.TryGetProperty("DisplayName", out var dn));
+ Assert.Equal("MyRule", dn.GetString());
+ Assert.True(parsed.RootElement.TryGetProperty("Category", out _));
+
+ // System properties must NOT appear in the snapshot.
+ Assert.False(parsed.RootElement.TryGetProperty("Timestamp", out _));
+ Assert.False(parsed.RootElement.TryGetProperty("odata.etag", out _));
+ }
+
+ [Fact]
+ public void BuildArchiveEntry_PopulatesOriginalKeysAndArchivedBy()
+ {
+ var handler = BuildHandler(new FakeCustomsArchiveRepository());
+ var source = new TableEntity(TenantId, "my-rule-id") { ["X"] = "Y" };
+
+ var entry = handler.BuildArchiveEntry(source, TenantId, Constants.TableNames.ImeLogPatterns, HistoryRowKey);
+
+ Assert.Equal(TenantId, entry.TenantId);
+ Assert.Equal(Constants.TableNames.ImeLogPatterns, entry.OriginalTable);
+ Assert.Equal(TenantId, entry.OriginalPartitionKey);
+ Assert.Equal("my-rule-id", entry.OriginalRowKey);
+ Assert.Equal(HistoryRowKey, entry.HistoryRowKey);
+ Assert.Equal("TenantOffboardingHandler", entry.ArchivedBy);
+ Assert.NotEqual(default, entry.ArchivedAt);
+ }
+
+ [Fact]
+ public async Task ReReOffboard_TwoRuns_CoExistInArchive_AsDistinctPartitions()
+ {
+ // PR3.B §3.5: each offboarding run gets its own PartitionKey
+ // ({tenantId}_{historyRowKey}), so a tenant offboarded twice produces two immutable
+ // archive partitions side-by-side.
+ var repo = new FakeCustomsArchiveRepository();
+
+ var run1 = "20260101000000000_" + TenantId;
+ var run2 = "20260518091523123_" + TenantId;
+
+ await repo.UpsertAsync(new TenantOffboardingCustomsArchiveEntry
+ {
+ PartitionKey = $"{TenantId}_{run1}",
+ RowKey = "GatherRules_aaa",
+ TenantId = TenantId,
+ OriginalTable = Constants.TableNames.GatherRules,
+ OriginalRowKey = "aaa",
+ HistoryRowKey = run1,
+ });
+
+ await repo.UpsertAsync(new TenantOffboardingCustomsArchiveEntry
+ {
+ PartitionKey = $"{TenantId}_{run2}",
+ RowKey = "GatherRules_aaa",
+ TenantId = TenantId,
+ OriginalTable = Constants.TableNames.GatherRules,
+ OriginalRowKey = "aaa",
+ HistoryRowKey = run2,
+ });
+
+ var count1 = await repo.CountByRunAndTableAsync(TenantId, run1, Constants.TableNames.GatherRules);
+ var count2 = await repo.CountByRunAndTableAsync(TenantId, run2, Constants.TableNames.GatherRules);
+
+ Assert.Equal(1, count1);
+ Assert.Equal(1, count2);
+ Assert.Equal(2, repo.Store.Count);
+ }
+
+ [Fact]
+ public async Task CountByRunAndTable_FiltersByOriginalTable()
+ {
+ var repo = new FakeCustomsArchiveRepository();
+ var run = "20260518091523123_" + TenantId;
+ var pk = $"{TenantId}_{run}";
+
+ await repo.UpsertAsync(new TenantOffboardingCustomsArchiveEntry { PartitionKey = pk, RowKey = "GatherRules_a", TenantId = TenantId, OriginalTable = "GatherRules", HistoryRowKey = run });
+ await repo.UpsertAsync(new TenantOffboardingCustomsArchiveEntry { PartitionKey = pk, RowKey = "GatherRules_b", TenantId = TenantId, OriginalTable = "GatherRules", HistoryRowKey = run });
+ await repo.UpsertAsync(new TenantOffboardingCustomsArchiveEntry { PartitionKey = pk, RowKey = "AnalyzeRules_a", TenantId = TenantId, OriginalTable = "AnalyzeRules", HistoryRowKey = run });
+
+ Assert.Equal(2, await repo.CountByRunAndTableAsync(TenantId, run, "GatherRules"));
+ Assert.Equal(1, await repo.CountByRunAndTableAsync(TenantId, run, "AnalyzeRules"));
+ Assert.Equal(0, await repo.CountByRunAndTableAsync(TenantId, run, "ImeLogPatterns"));
+ }
+
+ [Fact]
+ public async Task DeleteRunAsync_RemovesEveryEntryInPartition_LeavesOtherRunsAlone()
+ {
+ var repo = new FakeCustomsArchiveRepository();
+ var run1 = "20260101000000000_" + TenantId;
+ var run2 = "20260518091523123_" + TenantId;
+
+ await repo.UpsertAsync(new() { PartitionKey = $"{TenantId}_{run1}", RowKey = "GatherRules_a", TenantId = TenantId, OriginalTable = "GatherRules" });
+ await repo.UpsertAsync(new() { PartitionKey = $"{TenantId}_{run1}", RowKey = "GatherRules_b", TenantId = TenantId, OriginalTable = "GatherRules" });
+ await repo.UpsertAsync(new() { PartitionKey = $"{TenantId}_{run2}", RowKey = "GatherRules_a", TenantId = TenantId, OriginalTable = "GatherRules" });
+
+ var deleted = await repo.DeleteRunAsync(TenantId, run1);
+
+ Assert.Equal(2, deleted);
+ Assert.Single(repo.Store);
+ // The remaining row belongs to run2.
+ Assert.Single(repo.Store.Values, e => e.PartitionKey == $"{TenantId}_{run2}");
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerDrainContractTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerDrainContractTests.cs
new file mode 100644
index 00000000..7cd9f548
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerDrainContractTests.cs
@@ -0,0 +1,634 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure;
+using Azure.Data.Tables;
+using Azure.Storage.Blobs;
+using AutopilotMonitor.Functions.DataAccess.TableStorage;
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Functions.Services.Deletion;
+using AutopilotMonitor.Functions.Services.Notifications;
+using AutopilotMonitor.Functions.Services.Offboarding;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Models;
+using AutopilotMonitor.Shared.Models.Deletion;
+using AutopilotMonitor.Shared.Models.Offboarding;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests.Offboarding;
+
+///
+/// Pin the §4.5 Drain-Contract that prevents the "silent wipe" the user has been worried about.
+/// Every fail-closed branch must throw + the Marker must transition to Failed WITHOUT
+/// any SafeWipe being attempted. The 0-session happy path and the Rev-9-F1 Drain-Skip-Gate
+/// (post-crash idempotent resume) round out coverage for the surrounding state-machine.
+///
+public class TenantOffboardingHandlerDrainContractTests
+{
+ private const string TenantId = "11111111-1111-1111-1111-111111111111";
+ private const string HistoryRowKey = "20260518091523123_11111111-1111-1111-1111-111111111111";
+
+ // ── Fail-closed branches ────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task KillSwitchActive_ExpectationFails_NoSafeWipe()
+ {
+ var harness = Harness.New();
+ harness.SeedExpectations(new (string, string?, string, int)[]
+ {
+ ("session-1", null, nameof(SessionDeletionEnqueueOutcome.KillSwitchActive), 0),
+ });
+
+ await Assert.ThrowsAsync(() =>
+ harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0)));
+
+ Assert.Equal("Failed", harness.Repo.Markers[TenantId].Status);
+ Assert.Equal("killswitch", harness.Repo.Markers[TenantId].FailedPhase);
+ Assert.Equal(0, harness.SafeWipeProbe.WipeCallCount);
+ }
+
+ [Fact]
+ public async Task Poisoned_ExpectationFails_NoSafeWipe()
+ {
+ var harness = Harness.New();
+ harness.SeedExpectations(new (string, string?, string, int)[]
+ {
+ ("session-1", "m-1", nameof(SessionDeletionEnqueueOutcome.Poisoned), 0),
+ });
+
+ await Assert.ThrowsAsync(() =>
+ harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0)));
+
+ Assert.Equal("poisoned", harness.Repo.Markers[TenantId].FailedPhase);
+ Assert.Equal(0, harness.SafeWipeProbe.WipeCallCount);
+ }
+
+ [Fact]
+ public async Task AlreadyInFlight_With_NullManifestId_FailsClosed_Rev8F3()
+ {
+ // AlreadyInFlight + null ManifestId is the Preparing-without-snapshot state. There is
+ // no progress blob to drain against → fail closed immediately rather than waiting
+ // out the 2h drain budget.
+ var harness = Harness.New();
+ harness.SeedExpectations(new (string, string?, string, int)[]
+ {
+ ("session-1", null, nameof(SessionDeletionEnqueueOutcome.AlreadyInFlight), 0),
+ });
+
+ await Assert.ThrowsAsync(() =>
+ harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0)));
+
+ Assert.Equal("alreadyinflight_no_manifest", harness.Repo.Markers[TenantId].FailedPhase);
+ Assert.Equal(0, harness.SafeWipeProbe.WipeCallCount);
+ }
+
+ [Fact]
+ public async Task CasExhausted_RetriesUpToCap_ThenFailsClosed()
+ {
+ var harness = Harness.New();
+ harness.SeedExpectations(new (string, string?, string, int)[]
+ {
+ ("session-1", null, nameof(SessionDeletionEnqueueOutcome.CasExhausted),
+ TenantOffboardingHandler.MaxCasRetriesPerSession),
+ });
+
+ await Assert.ThrowsAsync(() =>
+ harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0)));
+
+ Assert.Equal("cas_exhausted", harness.Repo.Markers[TenantId].FailedPhase);
+ Assert.Equal(0, harness.SafeWipeProbe.WipeCallCount);
+ }
+
+ [Fact]
+ public async Task EnumeratorCrash_FirstAttempt_StampsEnumerationStartedAt_ThenPropagatesException()
+ {
+ // First-attempt crash: enumerator throws before any expectation is uploaded. The
+ // handler MUST stamp EnumerationStartedAt before touching the enumerator so the next
+ // pickup can tell first-try from re-pickup-after-crash. Exception propagates so the
+ // worker retries via visibility timeout.
+ var harness = Harness.New();
+ harness.EnumeratorThrow = new InvalidOperationException("simulated 503");
+
+ await Assert.ThrowsAsync(() =>
+ harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0)));
+
+ Assert.Equal(0, harness.SafeWipeProbe.WipeCallCount);
+ Assert.NotNull(harness.Repo.History[HistoryRowKey].EnumerationStartedAt);
+ Assert.Equal("InProgress", harness.Repo.History[HistoryRowKey].Status);
+ }
+
+ [Fact]
+ public async Task RePickup_MidEnumerationCrash_ESA_Set_ECBU_Null_FailsClosed_ExpectationsMissing()
+ {
+ // Plan §7.4 step 3 Crash-Verhalten: prior pickup stamped EnumerationStartedAt but
+ // crashed mid-iteration (ECBU still null). Re-pickup must NOT re-enumerate (tenant
+ // may be mid-mutation); it must fail-closed.
+ var harness = Harness.New();
+ harness.Repo.History[HistoryRowKey].EnumerationStartedAt = DateTime.UtcNow.AddMinutes(-5);
+ harness.Repo.History[HistoryRowKey].EnumerationCompletedBeforeUpload = null;
+ harness.EnumeratorYields.Add("session-X"); // would be enumerated if logic regressed
+
+ await Assert.ThrowsAsync(() =>
+ harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0)));
+
+ Assert.Equal("expectations_missing", harness.Repo.Markers[TenantId].FailedPhase);
+ Assert.Equal(0, harness.SafeWipeProbe.WipeCallCount);
+ harness.CascadeProducer.Verify(c => c.EnqueueAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never(), "Mid-enumeration crash on re-pickup must NEVER re-enumerate");
+ }
+
+ [Fact]
+ public async Task RePickup_UploadFailure_ESA_Set_ECBU_Set_NoBlob_ReRunsEnumerateAndUpload()
+ {
+ // Plan §7.4 step 3: when ECBU is set but blob is missing, the enumeration loop
+ // finished cleanly and only the upload failed. Re-pickup must RE-RUN enumerate +
+ // upload (idempotent — SessionDeletionProducer returns AlreadyInFlight for the same
+ // session id), NOT fail-closed.
+ var harness = Harness.New();
+ harness.Repo.History[HistoryRowKey].EnumerationStartedAt = DateTime.UtcNow.AddMinutes(-5);
+ harness.Repo.History[HistoryRowKey].EnumerationCompletedBeforeUpload = DateTime.UtcNow.AddMinutes(-4);
+ harness.EnumeratorYields.Add("session-A");
+ harness.EnumeratorYields.Add("session-B");
+ harness.CascadeProducer.Setup(c => c.EnqueueAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(
+ (_, sid, _, _, _, _) => new SessionDeletionEnqueueResult
+ {
+ Outcome = SessionDeletionEnqueueOutcome.AlreadyInFlight, // idempotent producer response
+ ManifestId = $"manifest-{sid}",
+ });
+
+ await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0));
+
+ // Both sessions re-enqueued (idempotent producer responses).
+ harness.CascadeProducer.Verify(c => c.EnqueueAsync(
+ It.IsAny(), "session-A", It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.AtLeastOnce, "Upload-retry path must re-enumerate session-A");
+ harness.CascadeProducer.Verify(c => c.EnqueueAsync(
+ It.IsAny(), "session-B", It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.AtLeastOnce, "Upload-retry path must re-enumerate session-B");
+ // Upload retry succeeded, drain probe says done → Completed.
+ Assert.Equal("Completed", harness.Repo.History[HistoryRowKey].Status);
+ // Marker is NOT Failed — this was an upload-retry, not a mid-enum crash.
+ Assert.NotEqual("expectations_missing", harness.Repo.Markers[TenantId].FailedPhase);
+ }
+
+ [Fact]
+ public async Task FirstTry_StampsBothMarkers_BeforeAndAfterEnumeration()
+ {
+ // Audit-trail invariant: ESA must be stamped BEFORE the enumerator runs (so a crash
+ // during iteration lands in the fail-closed branch next time); ECBU must be stamped
+ // AFTER the enumerator finishes (so an upload-failure-only crash lands in the
+ // upload-retry branch next time).
+ var harness = Harness.New();
+ harness.EnumeratorYields.Add("session-1");
+
+ await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0));
+
+ Assert.NotNull(harness.Repo.History[HistoryRowKey].EnumerationStartedAt);
+ Assert.NotNull(harness.Repo.History[HistoryRowKey].EnumerationCompletedBeforeUpload);
+ // Sanity: ESA <= ECBU (one was stamped before the loop, one after).
+ Assert.True(
+ harness.Repo.History[HistoryRowKey].EnumerationStartedAt
+ <= harness.Repo.History[HistoryRowKey].EnumerationCompletedBeforeUpload);
+ }
+
+ [Fact]
+ public async Task EnumerationIncomplete_FailsClosed()
+ {
+ var harness = Harness.New();
+ harness.Expectations.Seed(new OffboardingExpectations
+ {
+ SchemaVersion = 1,
+ TenantId = TenantId,
+ HistoryRowKey = HistoryRowKey,
+ CreatedAt = DateTime.UtcNow,
+ EnumerationCompleted = false, // ← the disambiguation Rev-7-F2 added
+ EnumeratedSessionCount = 5,
+ Expectations = Enumerable.Range(0, 5).Select(i =>
+ new OffboardingExpectation { SessionId = $"s{i}", Outcome = "Enqueued", ManifestId = $"m{i}" }).ToList(),
+ });
+
+ await Assert.ThrowsAsync(() =>
+ harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0)));
+
+ Assert.Equal("enumeration_incomplete", harness.Repo.Markers[TenantId].FailedPhase);
+ Assert.Equal(0, harness.SafeWipeProbe.WipeCallCount);
+ }
+
+ [Fact]
+ public async Task ExpectationsSizeMismatch_FailsClosed()
+ {
+ // expectations.Length != enumeratedSessionCount → fail-closed. Defends against a
+ // half-overwritten blob from a buggy re-uploader.
+ var harness = Harness.New();
+ harness.Expectations.Seed(new OffboardingExpectations
+ {
+ SchemaVersion = 1,
+ TenantId = TenantId,
+ HistoryRowKey = HistoryRowKey,
+ CreatedAt = DateTime.UtcNow,
+ EnumerationCompleted = true,
+ EnumeratedSessionCount = 5,
+ Expectations = new List
+ {
+ new() { SessionId = "s1", Outcome = "Enqueued", ManifestId = "m1" },
+ new() { SessionId = "s2", Outcome = "Enqueued", ManifestId = "m2" },
+ },
+ });
+
+ await Assert.ThrowsAsync(() =>
+ harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0)));
+
+ Assert.Equal("expectations_size_mismatch", harness.Repo.Markers[TenantId].FailedPhase);
+ }
+
+ [Fact]
+ public async Task DrainTimeout_AfterMaxPolls_FailsClosed()
+ {
+ // The cap (60 polls ≈ 2h) must produce a deterministic Failed transition rather than
+ // dragging on forever.
+ var harness = Harness.New();
+ harness.SeedExpectations(new (string, string?, string, int)[]
+ {
+ ("session-1", "m-1", nameof(SessionDeletionEnqueueOutcome.Enqueued), 0),
+ });
+ harness.DrainProbe.Setup(p => p.IsCascadeCompletedAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(false); // never settles
+
+ await Assert.ThrowsAsync(() =>
+ harness.Sut.HandleAsync(harness.Envelope(drainPollCount: TenantOffboardingHandler.MaxDrainPolls - 1)));
+
+ Assert.Equal("drain_timeout", harness.Repo.Markers[TenantId].FailedPhase);
+ }
+
+ // ── Happy paths ─────────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task ZeroSessions_EnumerationCompleted_DrainOk_ProceedsToWipeAndCompleted()
+ {
+ // Plan §4.5: empty expectations + enumerationCompleted=true is the legitimate
+ // happy path for a tenant with no sessions left.
+ var harness = Harness.New();
+ // Enumerator yields nothing — handler will upload an Expectations blob with
+ // enumerationCompleted=true, enumeratedSessionCount=0.
+
+ await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0));
+
+ Assert.Equal("Completed", harness.Repo.History[HistoryRowKey].Status);
+ Assert.NotNull(harness.Repo.History[HistoryRowKey].CompletedAt);
+ Assert.NotNull(harness.Repo.History[HistoryRowKey].DrainCompletedAt);
+ Assert.Equal("Completed", harness.Repo.Markers[TenantId].Status);
+ Assert.NotNull(harness.Repo.Markers[TenantId].CompletedAt);
+ Assert.True(harness.SafeWipeProbe.WipeCallCount > 0,
+ "SafeWipe must have run across the tenant tables");
+ Assert.False(harness.Expectations.BlobExists(TenantId, HistoryRowKey),
+ "Phase 2.G must delete the Expectations blob as its last step");
+ }
+
+ [Fact]
+ public async Task SessionNotFound_CountsAsSatisfied_NoDrainProbeCall()
+ {
+ var harness = Harness.New();
+ harness.SeedExpectations(new (string, string?, string, int)[]
+ {
+ ("session-1", null, nameof(SessionDeletionEnqueueOutcome.SessionNotFound), 0),
+ });
+
+ await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0));
+
+ Assert.Equal("Completed", harness.Repo.History[HistoryRowKey].Status);
+ harness.DrainProbe.Verify(p => p.IsCascadeCompletedAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never(), "SessionNotFound is a no-op — drain probe must NOT be called for that expectation");
+ }
+
+ // ── Rev-9-F1 Drain-Skip-Gate ───────────────────────────────────────────────
+
+ [Fact]
+ public async Task Rev9F1_DrainCompletedAtSet_SkipsDrainPredicate_AndRunsPostDrainPhasesIdempotently()
+ {
+ // The crash-recovery scenario: drain ran once and stamped DrainCompletedAt, then we
+ // crashed somewhere in 2.D-G. Re-pickup must NOT re-evaluate the drain predicate (the
+ // progress blobs are now gone in 2.E) and must still land us on Completed.
+ var harness = Harness.New();
+ harness.Repo.History[HistoryRowKey].DrainCompletedAt = DateTime.UtcNow.AddMinutes(-10);
+ harness.Repo.History[HistoryRowKey].Status = "InProgress";
+ harness.Repo.Markers[TenantId].Status = "InProgress";
+
+ await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0));
+
+ Assert.Equal("Completed", harness.Repo.History[HistoryRowKey].Status);
+ harness.DrainProbe.Verify(p => p.IsCascadeCompletedAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never(), "When DrainCompletedAt is already set the drain probe must NOT be called");
+ harness.Enumerator.Verify(e => e.EnumerateAsync(It.IsAny(), It.IsAny()),
+ Times.Never(), "Drain-skip-gate must also bypass the enumerate+enqueue phase");
+ }
+
+ [Fact]
+ public async Task History_AlreadyCompleted_IsNoOp()
+ {
+ var harness = Harness.New();
+ harness.Repo.History[HistoryRowKey].Status = "Completed";
+ harness.Repo.History[HistoryRowKey].CompletedAt = DateTime.UtcNow;
+
+ await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0));
+
+ // No additional writes — handler returned early.
+ Assert.Equal(0, harness.SafeWipeProbe.WipeCallCount);
+ }
+
+ // ── Drain re-poll ───────────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task DrainNotSettled_BelowCap_ReEnqueuesWithDelayAndPollIncrement()
+ {
+ var harness = Harness.New();
+ harness.SeedExpectations(new (string, string?, string, int)[]
+ {
+ ("session-1", "m-1", nameof(SessionDeletionEnqueueOutcome.Enqueued), 0),
+ });
+ harness.DrainProbe.Setup(p => p.IsCascadeCompletedAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(false);
+
+ await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 3));
+
+ harness.ReEnqueuer.Verify(e => e.EnqueueAsync(
+ It.Is(env => env.DrainPollCount == 4),
+ TenantOffboardingHandler.DrainPollDelay,
+ It.IsAny()),
+ Times.Once);
+ // History should NOT be Completed yet.
+ Assert.Equal("InProgress", harness.Repo.History[HistoryRowKey].Status);
+ Assert.Null(harness.Repo.History[HistoryRowKey].DrainCompletedAt);
+ }
+
+ // ── Harness ─────────────────────────────────────────────────────────────────
+
+ private sealed class Harness
+ {
+ public TenantOffboardingHandler Sut { get; private set; } = default!;
+ public FakeOffboardingAuditRepository Repo { get; } = new();
+ public FakeOffboardingExpectationsStore Expectations { get; } = new();
+ public Mock DrainProbe { get; } = new();
+ public Mock ReEnqueuer { get; } = new();
+ public Mock CascadeProducer { get; } = new();
+ public Mock Enumerator { get; }
+ public CountingSafeWipeService SafeWipeProbe { get; } = new();
+ public Mock Maintenance { get; } = new();
+ public List EnumeratorYields { get; } = new();
+ public Exception? EnumeratorThrow { get; set; }
+
+ private Harness()
+ {
+ var maintenanceForEnumerator = new Mock();
+ Enumerator = new Mock(maintenanceForEnumerator.Object);
+ }
+
+ public static Harness New()
+ {
+ var h = new Harness();
+ h.SeedInitialAudit();
+
+ // Default enumerator returns the (mutable) Sessions list. Tests append to it
+ // before invoking the handler.
+ h.Enumerator
+ .Setup(e => e.EnumerateAsync(It.IsAny(), It.IsAny()))
+ .Returns((_, ct) => h.EnumerateSessionsAsync(ct));
+
+ h.DrainProbe.Setup(p => p.IsCascadeCompletedAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(true); // happy default — drain completes immediately
+
+ h.CascadeProducer.Setup(c => c.EnqueueAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(
+ (_, sid, _, _, _, _) => new SessionDeletionEnqueueResult
+ {
+ Outcome = SessionDeletionEnqueueOutcome.Enqueued,
+ ManifestId = $"manifest-{sid}",
+ });
+
+ h.Maintenance.Setup(m => m.LogAuditEntryAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny?>()))
+ .ReturnsAsync(true);
+
+ var opsService = BuildRealOpsService();
+
+ var unusedTableStorage = new Mock(
+ Mock.Of(),
+ NullLogger.Instance).Object;
+
+ h.Sut = new NoopCustomsArchivingHandler(
+ h.Repo,
+ h.Enumerator.Object,
+ h.CascadeProducer.Object,
+ h.Expectations,
+ h.DrainProbe.Object,
+ h.SafeWipeProbe,
+ unusedTableStorage,
+ h.Maintenance.Object,
+ h.ReEnqueuer.Object,
+ opsService,
+ Mock.Of(),
+ NullLogger.Instance);
+
+ return h;
+ }
+
+ private async IAsyncEnumerable EnumerateSessionsAsync(
+ [EnumeratorCancellation] CancellationToken ct)
+ {
+ if (EnumeratorThrow != null) throw EnumeratorThrow;
+ foreach (var s in EnumeratorYields)
+ {
+ ct.ThrowIfCancellationRequested();
+ yield return s;
+ }
+ await Task.CompletedTask;
+ }
+
+ public TenantOffboardingEnvelope Envelope(int drainPollCount) => new()
+ {
+ TenantId = TenantId,
+ HistoryPartitionKey = Constants.OffboardingPartitionKeys.History,
+ HistoryRowKey = HistoryRowKey,
+ InitiatedBy = "alice@contoso.invalid",
+ InitiatedAt = DateTime.UtcNow.AddMinutes(-1),
+ EnqueuedAt = DateTime.UtcNow,
+ DrainPollCount = drainPollCount,
+ };
+
+ public void SeedExpectations(IEnumerable<(string sessionId, string? manifestId, string outcome, int retryCount)> rows)
+ {
+ var list = rows.Select(r => new OffboardingExpectation
+ {
+ SessionId = r.sessionId,
+ ManifestId = r.manifestId,
+ Outcome = r.outcome,
+ RetryCount = r.retryCount,
+ }).ToList();
+ Expectations.Seed(new OffboardingExpectations
+ {
+ SchemaVersion = 1,
+ TenantId = TenantId,
+ HistoryRowKey = HistoryRowKey,
+ CreatedAt = DateTime.UtcNow,
+ EnumerationCompleted = true,
+ EnumeratedSessionCount = list.Count,
+ Expectations = list,
+ });
+ }
+
+ private void SeedInitialAudit()
+ {
+ Repo.History[HistoryRowKey] = new OffboardingHistoryEntry
+ {
+ PartitionKey = Constants.OffboardingPartitionKeys.History,
+ RowKey = HistoryRowKey,
+ TenantId = TenantId,
+ DomainName = "contoso.invalid",
+ InitiatedBy = "alice@contoso.invalid",
+ OffboardedAt = DateTime.UtcNow.AddMinutes(-1),
+ Status = "Initiated",
+ };
+ Repo.Markers[TenantId] = new OffboardingMarkerEntry
+ {
+ PartitionKey = Constants.OffboardingPartitionKeys.Marker,
+ RowKey = TenantId,
+ TenantId = TenantId,
+ OffboardingHistoryRowKey = HistoryRowKey,
+ InitiatedAt = DateTime.UtcNow.AddMinutes(-1),
+ InitiatedBy = "alice@contoso.invalid",
+ Status = "Initiated",
+ };
+ Repo.Pointers[TenantId] = (new OffboardingByTenantPointer
+ {
+ PartitionKey = Constants.OffboardingPartitionKeys.ByTenant,
+ RowKey = TenantId,
+ TenantId = TenantId,
+ LatestHistoryRowKey = HistoryRowKey,
+ LatestStatus = "Initiated",
+ LatestUpdatedAt = DateTime.UtcNow.AddMinutes(-1),
+ OffboardCount = 1,
+ }, "\"0xFAKE_PTR_1\"");
+ }
+
+ private static OpsEventService BuildRealOpsService()
+ {
+ var opsRepo = new Mock();
+ opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny())).Returns(Task.CompletedTask);
+ var memCache = new MemoryCache(new MemoryCacheOptions());
+ var adminConfig = new Mock(
+ Mock.Of(), NullLogger.Instance, memCache);
+ var alertDispatch = new OpsAlertDispatchService(
+ adminConfig.Object,
+ new TelegramNotificationService(new HttpClient(), Mock.Of(),
+ NullLogger.Instance),
+ new WebhookNotificationService(new HttpClient(),
+ NullLogger.Instance),
+ NullLogger.Instance);
+ return new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch);
+ }
+
+ }
+
+ ///
+ /// that records call counts without performing any storage IO.
+ /// Tests use this to assert that fail-closed paths NEVER touch the wipe methods. Now that
+ /// exposes the wipe methods as virtual, the production
+ /// dispatch lands here.
+ ///
+ private sealed class CountingSafeWipeService : SafeWipeService
+ {
+ public int WipeCallCount { get; private set; }
+
+ public CountingSafeWipeService()
+ : base(
+ new TableStorageService(Mock.Of(), NullLogger.Instance),
+ new BlobStorageService(new BlobServiceClient("UseDevelopmentStorage=true"),
+ NullLogger.Instance, usesManagedIdentity: false),
+ NullLogger.Instance)
+ {
+ }
+
+ public override Task WipeByExactPartitionAsync(string tableName, string normalizedTenantId, CancellationToken ct = default)
+ {
+ WipeCallCount++;
+ return Task.FromResult(0);
+ }
+
+ public override Task WipeByCompositePartitionRangeAsync(string tableName, string normalizedTenantId, CancellationToken ct = default)
+ {
+ WipeCallCount++;
+ return Task.FromResult(0);
+ }
+
+ public override Task WipeByDiscriminatorAndTenantPropertyAsync(string tableName, string discriminator, string normalizedTenantId, CancellationToken ct = default)
+ {
+ WipeCallCount++;
+ return Task.FromResult(0);
+ }
+
+ public override Task WipeByTenantIdPropertyAsync(string tableName, string normalizedTenantId, CancellationToken ct = default)
+ {
+ WipeCallCount++;
+ return Task.FromResult(0);
+ }
+
+ public override Task WipeBlobsByTenantPrefixAsync(string containerName, string normalizedTenantId, CancellationToken ct = default)
+ {
+ WipeCallCount++;
+ return Task.FromResult(0);
+ }
+ }
+
+ ///
+ /// Subclass that no-ops the customs archive-then-wipe loop so the existing drain-contract
+ /// tests don't need a working TableStorageService for the 3 rules tables. PR3.B-specific
+ /// archive behaviour has its own test class with proper TableClient mocks.
+ ///
+ private sealed class NoopCustomsArchivingHandler : TenantOffboardingHandler
+ {
+ public NoopCustomsArchivingHandler(
+ IOffboardingAuditRepository auditRepo,
+ OffboardingSessionEnumerator enumerator,
+ ISessionDeletionEnqueuer cascadeEnqueuer,
+ IOffboardingExpectationsStore expectations,
+ IDeletionProgressDrainProbe drainProbe,
+ SafeWipeService safeWipe,
+ TableStorageService storage,
+ IMaintenanceRepository maintenance,
+ ITenantOffboardingEnqueuer reEnqueuer,
+ OpsEventService opsEvents,
+ ITenantCustomsArchiveRepository customsArchive,
+ ILogger logger)
+ : base(auditRepo, enumerator, cascadeEnqueuer, expectations, drainProbe, safeWipe,
+ storage, maintenance, reEnqueuer, opsEvents, customsArchive, logger)
+ {
+ }
+
+ internal override Task ArchiveAndWipeRulesTableAsync(
+ string tableName, string tenantId, string historyRowKey, CancellationToken ct)
+ => Task.CompletedTask;
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs
new file mode 100644
index 00000000..d1323c65
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs
@@ -0,0 +1,368 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.Data.Tables;
+using Azure.Storage.Blobs;
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Functions.Services.Deletion;
+using AutopilotMonitor.Functions.Services.Notifications;
+using AutopilotMonitor.Functions.Services.Offboarding;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Models;
+using AutopilotMonitor.Shared.Models.Deletion;
+using AutopilotMonitor.Shared.Models.Offboarding;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests.Offboarding;
+
+///
+/// Review-Fix Findings 2 + 3:
+///
+/// - (Finding 3) History terminal status MUST be the LAST write in both Completed and
+/// Failed paths. A crash between any side-effect (marker/pointer/blob/audit/ops) and
+/// the History commit must produce a re-pickup that re-runs the side-effects
+/// idempotently — never a re-pickup that returns early with dangling state.
+/// - (Finding 2)
+/// drives History/Pointer/Marker to Failed (with FailedAt/FailedPhase/ErrorMessage/RetryCount)
+/// so the operator dashboard reflects the dead-letter rather than leaving the tenant
+/// hanging in InProgress.
+///
+///
+public class TenantOffboardingHandlerOrderingTests
+{
+ private const string TenantId = "11111111-1111-1111-1111-111111111111";
+ private const string HistoryRowKey = "20260518091523123_11111111-1111-1111-1111-111111111111";
+
+ // ── Finding 3 — Completed-path ordering ─────────────────────────────────────
+
+ [Fact]
+ public async Task PostDrain_OrderingInvariant_MarkerCompletedBeforeHistoryCompleted()
+ {
+ var harness = Harness.New();
+
+ await harness.Sut.HandleAsync(harness.Envelope());
+
+ // Both writes happen. The invariant: Marker.Status="Completed" lands BEFORE
+ // History.Status="Completed" — the Repo's MarkerWrites/HistoryWrites lists capture
+ // the order via Append-on-Write.
+ var lastMarker = harness.Repo.MarkerWrites.LastIndexOf("Completed");
+ var lastHistoryCompleted = harness.Repo.HistoryWrites.LastIndexOf("Completed");
+ Assert.True(lastMarker >= 0, "Marker.Status=Completed write never happened");
+ Assert.True(lastHistoryCompleted >= 0, "History.Status=Completed write never happened");
+ // Marker write happens before the FINAL history write — both lists are sequenced
+ // independently per partition, but the order WITHIN the handler's invocation is what
+ // matters; we capture each by counting writes-before.
+ Assert.True(
+ harness.Repo.MarkerWrites.Count >= 1,
+ "Marker must be written at least once with Completed");
+ // Last history write IS the Completed-commit. Anything that happened before that in
+ // MarkerWrites was the Completed transition.
+ Assert.Equal("Completed", harness.Repo.MarkerWrites.Last());
+ }
+
+ [Fact]
+ public async Task PostDrain_SimulatedCrashBetweenMarkerAndHistory_RepickupCompletesIdempotently()
+ {
+ // Step 1: first invocation — completes normally.
+ var harness = Harness.New();
+ await harness.Sut.HandleAsync(harness.Envelope());
+ Assert.Equal("Completed", harness.Repo.History[HistoryRowKey].Status);
+ Assert.Equal("Completed", harness.Repo.Markers[TenantId].Status);
+
+ // Step 2: simulate crash by manually re-stamping the history to InProgress while keeping
+ // DrainCompletedAt set (this is what a re-pickup after a Phase-2.G crash looks like).
+ // The marker is left as Completed (already updated before the crash), the post-drain
+ // side-effects all idempotently re-execute.
+ harness.Repo.History[HistoryRowKey].Status = "InProgress";
+ harness.Repo.History[HistoryRowKey].CompletedAt = null;
+ var wipeCallsBefore = harness.SafeWipeProbe.WipeCallCount;
+
+ await harness.Sut.HandleAsync(harness.Envelope());
+
+ // History ends Completed again, marker stays Completed, side-effects re-ran (idempotent).
+ Assert.Equal("Completed", harness.Repo.History[HistoryRowKey].Status);
+ Assert.Equal("Completed", harness.Repo.Markers[TenantId].Status);
+ Assert.True(harness.SafeWipeProbe.WipeCallCount > wipeCallsBefore,
+ "Re-pickup must re-run the SafeWipe phase (idempotent — finds 0 rows the second time)");
+ }
+
+ // ── Finding 3 — Failed-path ordering ────────────────────────────────────────
+
+ [Fact]
+ public async Task FailAsync_OrderingInvariant_MarkerFailedBeforeHistoryFailed()
+ {
+ var harness = Harness.New();
+ // Force fail-closed via KillSwitchActive expectation.
+ harness.Expectations.Seed(new OffboardingExpectations
+ {
+ SchemaVersion = 1, TenantId = TenantId, HistoryRowKey = HistoryRowKey,
+ CreatedAt = DateTime.UtcNow, EnumerationCompleted = true, EnumeratedSessionCount = 1,
+ Expectations = new List
+ {
+ new() { SessionId = "session-1", Outcome = "KillSwitchActive" },
+ },
+ });
+
+ await Assert.ThrowsAsync(() =>
+ harness.Sut.HandleAsync(harness.Envelope()));
+
+ // Marker must be Failed; History must be Failed too. The LAST write to history is the
+ // Failed-commit — anything that happened before (marker, pointer, ops) is part of the
+ // "side-effects first" contract.
+ Assert.Equal("Failed", harness.Repo.Markers[TenantId].Status);
+ Assert.Equal("killswitch", harness.Repo.Markers[TenantId].FailedPhase);
+ Assert.NotNull(harness.Repo.Markers[TenantId].FailedAt);
+ Assert.Equal("Failed", harness.Repo.History[HistoryRowKey].Status);
+ // History was already InProgress + Marker write between, so MarkerWrites holds "Failed"
+ // as its latest, and HistoryWrites ends with "Failed".
+ Assert.Equal("Failed", harness.Repo.MarkerWrites.Last());
+ Assert.Equal("Failed", harness.Repo.HistoryWrites.Last());
+ }
+
+ // ── Finding 2 — Worker poison transition via MarkEnvelopeFailedFromPoisonAsync ──
+
+ [Fact]
+ public async Task MarkEnvelopeFailedFromPoison_TransitionsAllThreeStatesToFailed()
+ {
+ var harness = Harness.New();
+ // History/Pointer/Marker pre-seeded to InProgress (default Harness seed is Initiated;
+ // bump to InProgress to mirror the in-flight scenario).
+ harness.Repo.History[HistoryRowKey].Status = "InProgress";
+ harness.Repo.Markers[TenantId].Status = "InProgress";
+ var existingPointer = harness.Repo.Pointers[TenantId];
+ existingPointer.Pointer.LatestStatus = "InProgress";
+
+ await harness.Sut.MarkEnvelopeFailedFromPoisonAsync(harness.Envelope(), dequeueCount: 5);
+
+ Assert.Equal("Failed", harness.Repo.History[HistoryRowKey].Status);
+ Assert.Equal("Failed", harness.Repo.Markers[TenantId].Status);
+ Assert.Equal("Failed", harness.Repo.Pointers[TenantId].Pointer.LatestStatus);
+ Assert.Equal("max_dequeue", harness.Repo.Markers[TenantId].FailedPhase);
+ Assert.NotNull(harness.Repo.Markers[TenantId].FailedAt);
+ Assert.Equal(5, harness.Repo.History[HistoryRowKey].RetryCount);
+ Assert.Contains("poisoned after 5", harness.Repo.History[HistoryRowKey].ErrorMessage);
+ }
+
+ [Fact]
+ public async Task MarkEnvelopeFailedFromPoison_AlreadyCompleted_IsNoOp()
+ {
+ // If the handler successfully completed BEFORE max-dequeue (e.g. last attempt
+ // succeeded but the worker's dequeue counter still tripped the threshold), the
+ // poison transition must not downgrade Completed → Failed.
+ var harness = Harness.New();
+ harness.Repo.History[HistoryRowKey].Status = "Completed";
+ harness.Repo.History[HistoryRowKey].CompletedAt = DateTime.UtcNow;
+ harness.Repo.Markers[TenantId].Status = "Completed";
+ harness.Repo.Markers[TenantId].CompletedAt = DateTime.UtcNow;
+
+ await harness.Sut.MarkEnvelopeFailedFromPoisonAsync(harness.Envelope(), dequeueCount: 5);
+
+ Assert.Equal("Completed", harness.Repo.History[HistoryRowKey].Status);
+ Assert.Equal("Completed", harness.Repo.Markers[TenantId].Status);
+ }
+
+ [Fact]
+ public async Task MarkEnvelopeFailedFromPoison_AlreadyFailed_IsNoOp()
+ {
+ var harness = Harness.New();
+ harness.Repo.History[HistoryRowKey].Status = "Failed";
+ harness.Repo.History[HistoryRowKey].ErrorMessage = "drain_timeout";
+ harness.Repo.Markers[TenantId].Status = "Failed";
+ harness.Repo.Markers[TenantId].FailedPhase = "drain_timeout";
+ var beforeWrites = harness.Repo.HistoryWrites.Count;
+
+ await harness.Sut.MarkEnvelopeFailedFromPoisonAsync(harness.Envelope(), dequeueCount: 5);
+
+ Assert.Equal("Failed", harness.Repo.History[HistoryRowKey].Status);
+ Assert.Equal("drain_timeout", harness.Repo.Markers[TenantId].FailedPhase); // not overwritten
+ Assert.Equal(beforeWrites, harness.Repo.HistoryWrites.Count);
+ }
+
+ [Fact]
+ public async Task MarkEnvelopeFailedFromPoison_MissingHistory_LogsAndReturnsCleanly()
+ {
+ var harness = Harness.New();
+ harness.Repo.History.Remove(HistoryRowKey);
+
+ // Must NOT throw — the worker poison-move would otherwise lose its envelope and the
+ // operator would have no signal at all.
+ await harness.Sut.MarkEnvelopeFailedFromPoisonAsync(harness.Envelope(), dequeueCount: 5);
+ }
+
+ // ── Harness (copied minimal — only what these tests need) ───────────────────
+
+ private sealed class Harness
+ {
+ public TenantOffboardingHandler Sut { get; private set; } = default!;
+ public FakeOffboardingAuditRepository Repo { get; } = new();
+ public FakeOffboardingExpectationsStore Expectations { get; } = new();
+ public Mock DrainProbe { get; } = new();
+ public Mock ReEnqueuer { get; } = new();
+ public Mock CascadeProducer { get; } = new();
+ public Mock Enumerator { get; }
+ public CountingSafeWipeService SafeWipeProbe { get; } = new();
+ public Mock Maintenance { get; } = new();
+ public List EnumeratorYields { get; } = new();
+
+ private Harness()
+ {
+ Enumerator = new Mock(new Mock().Object);
+ }
+
+ public static Harness New()
+ {
+ var h = new Harness();
+ h.SeedInitialAudit();
+
+ h.Enumerator
+ .Setup(e => e.EnumerateAsync(It.IsAny(), It.IsAny()))
+ .Returns((_, ct) => EmptyEnumerateAsync(ct));
+
+ h.DrainProbe.Setup(p => p.IsCascadeCompletedAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(true);
+
+ h.CascadeProducer.Setup(c => c.EnqueueAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(
+ (_, sid, _, _, _, _) => new SessionDeletionEnqueueResult
+ {
+ Outcome = SessionDeletionEnqueueOutcome.Enqueued,
+ ManifestId = $"manifest-{sid}",
+ });
+
+ h.Maintenance.Setup(m => m.LogAuditEntryAsync(
+ It.IsAny(), It.IsAny(), It.IsAny(),
+ It.IsAny(), It.IsAny(), It.IsAny?>()))
+ .ReturnsAsync(true);
+
+ var unusedTableStorage = new Mock(
+ Mock.Of(),
+ NullLogger.Instance).Object;
+
+ h.Sut = new NoopCustomsArchivingOrderingHandler(
+ h.Repo,
+ h.Enumerator.Object,
+ h.CascadeProducer.Object,
+ h.Expectations,
+ h.DrainProbe.Object,
+ h.SafeWipeProbe,
+ unusedTableStorage,
+ h.Maintenance.Object,
+ h.ReEnqueuer.Object,
+ BuildOpsService(),
+ Mock.Of(),
+ NullLogger.Instance);
+
+ return h;
+ }
+
+ public TenantOffboardingEnvelope Envelope() => new()
+ {
+ TenantId = TenantId,
+ HistoryPartitionKey = Constants.OffboardingPartitionKeys.History,
+ HistoryRowKey = HistoryRowKey,
+ InitiatedBy = "alice@contoso.invalid",
+ InitiatedAt = DateTime.UtcNow.AddMinutes(-1),
+ EnqueuedAt = DateTime.UtcNow,
+ DrainPollCount = 0,
+ };
+
+ private static async IAsyncEnumerable EmptyEnumerateAsync(
+ [EnumeratorCancellation] CancellationToken ct)
+ {
+ await Task.CompletedTask;
+ yield break;
+ }
+
+ private void SeedInitialAudit()
+ {
+ Repo.History[HistoryRowKey] = new OffboardingHistoryEntry
+ {
+ PartitionKey = Constants.OffboardingPartitionKeys.History,
+ RowKey = HistoryRowKey,
+ TenantId = TenantId,
+ DomainName = "contoso.invalid",
+ InitiatedBy = "alice@contoso.invalid",
+ OffboardedAt = DateTime.UtcNow.AddMinutes(-1),
+ Status = "Initiated",
+ };
+ Repo.Markers[TenantId] = new OffboardingMarkerEntry
+ {
+ PartitionKey = Constants.OffboardingPartitionKeys.Marker,
+ RowKey = TenantId,
+ TenantId = TenantId,
+ OffboardingHistoryRowKey = HistoryRowKey,
+ InitiatedAt = DateTime.UtcNow.AddMinutes(-1),
+ InitiatedBy = "alice@contoso.invalid",
+ Status = "Initiated",
+ };
+ Repo.Pointers[TenantId] = (new OffboardingByTenantPointer
+ {
+ PartitionKey = Constants.OffboardingPartitionKeys.ByTenant,
+ RowKey = TenantId,
+ TenantId = TenantId,
+ LatestHistoryRowKey = HistoryRowKey,
+ LatestStatus = "Initiated",
+ LatestUpdatedAt = DateTime.UtcNow.AddMinutes(-1),
+ OffboardCount = 1,
+ }, "\"0xFAKE_PTR_1\"");
+ }
+
+ private static OpsEventService BuildOpsService()
+ {
+ var opsRepo = new Mock();
+ opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny())).Returns(Task.CompletedTask);
+ var memCache = new MemoryCache(new MemoryCacheOptions());
+ var adminConfig = new Mock(
+ Mock.Of(), NullLogger.Instance, memCache);
+ var alertDispatch = new OpsAlertDispatchService(
+ adminConfig.Object,
+ new TelegramNotificationService(new HttpClient(), Mock.Of(),
+ NullLogger.Instance),
+ new WebhookNotificationService(new HttpClient(),
+ NullLogger.Instance),
+ NullLogger.Instance);
+ return new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch);
+ }
+ }
+
+ private sealed class CountingSafeWipeService : SafeWipeService
+ {
+ public int WipeCallCount { get; private set; }
+ public CountingSafeWipeService() : base(
+ new TableStorageService(Mock.Of(), NullLogger.Instance),
+ new BlobStorageService(new BlobServiceClient("UseDevelopmentStorage=true"),
+ NullLogger.Instance, usesManagedIdentity: false),
+ NullLogger.Instance) { }
+ public override Task WipeByExactPartitionAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
+ public override Task WipeByCompositePartitionRangeAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
+ public override Task WipeByDiscriminatorAndTenantPropertyAsync(string t, string d, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
+ public override Task WipeByTenantIdPropertyAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
+ public override Task WipeBlobsByTenantPrefixAsync(string c, string i, CancellationToken ct = default) { WipeCallCount++; return Task.FromResult(0); }
+ }
+
+ private sealed class NoopCustomsArchivingOrderingHandler : TenantOffboardingHandler
+ {
+ public NoopCustomsArchivingOrderingHandler(
+ IOffboardingAuditRepository a, OffboardingSessionEnumerator e, ISessionDeletionEnqueuer c,
+ IOffboardingExpectationsStore exp, IDeletionProgressDrainProbe d, SafeWipeService sw,
+ TableStorageService s, IMaintenanceRepository m, ITenantOffboardingEnqueuer re,
+ OpsEventService o, ITenantCustomsArchiveRepository ca, ILogger log)
+ : base(a, e, c, exp, d, sw, s, m, re, o, ca, log) { }
+
+ internal override Task ArchiveAndWipeRulesTableAsync(
+ string tableName, string tenantId, string historyRowKey, CancellationToken ct)
+ => Task.CompletedTask;
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingWorkerPoisonTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingWorkerPoisonTests.cs
new file mode 100644
index 00000000..ebb65cd9
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingWorkerPoisonTests.cs
@@ -0,0 +1,300 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure;
+using Azure.Data.Tables;
+using Azure.Storage.Blobs;
+using Azure.Storage.Queues;
+using Azure.Storage.Queues.Models;
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Functions.Services.Deletion;
+using AutopilotMonitor.Functions.Services.Notifications;
+using AutopilotMonitor.Functions.Services.Offboarding;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Models;
+using AutopilotMonitor.Shared.Models.Deletion;
+using AutopilotMonitor.Shared.Models.Offboarding;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Newtonsoft.Json;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests.Offboarding;
+
+///
+/// Review-Fix (second-pass) Finding 1:
+/// MUST drive the durable Failed-state transition BEFORE the poison-queue send + main-queue
+/// delete. If the transition fails for any reason (storage outage, OpsEvent service down,
+/// pointer-CAS contention), the main-queue message must stay visible so a subsequent dequeue
+/// can retry the transition.
+///
+public class TenantOffboardingWorkerPoisonTests
+{
+ private const string TenantId = "11111111-1111-1111-1111-111111111111";
+ private const string HistoryRowKey = "20260518091523123_11111111-1111-1111-1111-111111111111";
+
+ [Fact]
+ public async Task PoisonPath_FailedTransitionSucceeds_ThenSendToPoisonAndDelete()
+ {
+ // Happy poison path: marker/pointer/history → Failed first, THEN message goes to
+ // poison queue, THEN main message is deleted.
+ var harness = new Harness();
+
+ await harness.InvokeMoveToPoisonAsync(harness.BuildMessage(dequeueCount: 6));
+
+ Assert.Equal("Failed", harness.Repo.History[HistoryRowKey].Status);
+ Assert.Equal("Failed", harness.Repo.Markers[TenantId].Status);
+ Assert.Equal("max_dequeue", harness.Repo.Markers[TenantId].FailedPhase);
+
+ Assert.Equal(1, harness.PoisonSendCount);
+ Assert.Equal(1, harness.MainDeleteCount);
+ }
+
+ [Fact]
+ public async Task PoisonPath_FailedTransitionThrows_NoPoisonSend_NoMainDelete()
+ {
+ // Critical Finding 1 assertion: if MarkEnvelopeFailedFromPoisonAsync throws, the
+ // poison send + main delete MUST NOT happen — otherwise the operator would see a
+ // tenant stuck in InProgress with no queue message left to retry from.
+ var harness = new Harness();
+ // Simulate a transient storage failure inside FailAsync's final commit.
+ harness.Repo.ThrowOnNextHistoryUpsert = new InvalidOperationException("simulated storage 503");
+
+ await harness.InvokeMoveToPoisonAsync(harness.BuildMessage(dequeueCount: 6));
+
+ Assert.Equal(0, harness.PoisonSendCount);
+ Assert.Equal(0, harness.MainDeleteCount);
+ // History stays InProgress — the next visibility-timeout retry will re-attempt the
+ // whole MoveToPoisonAsync sequence. Marker may or may not be Failed depending on how
+ // far FailAsync got before the throw; the critical invariant is that the queue
+ // message stays so the operator-facing state CAN converge on the next dequeue.
+ Assert.Equal("InProgress", harness.Repo.History[HistoryRowKey].Status);
+ }
+
+ [Fact]
+ public async Task PoisonPath_PoisonSendFails_AfterFailedTransition_LeavesMessageButStateIsDurable()
+ {
+ // The transition succeeded → state is durable. The poison send fails (transient).
+ // The main delete is skipped. The message reappears via visibility-timeout. On the
+ // next pickup, MarkEnvelopeFailedFromPoisonAsync sees the terminal state and is a
+ // no-op, then the poison send retries.
+ var harness = new Harness(poisonSendThrows: true);
+
+ await harness.InvokeMoveToPoisonAsync(harness.BuildMessage(dequeueCount: 6));
+
+ Assert.Equal("Failed", harness.Repo.History[HistoryRowKey].Status);
+ Assert.Equal(1, harness.PoisonSendCount); // attempted
+ Assert.Equal(0, harness.MainDeleteCount); // skipped because poison send threw
+ }
+
+ [Fact]
+ public async Task PoisonPath_MalformedEnvelope_SkipsTransition_StillPoisonsTheMessage()
+ {
+ // Malformed envelope can't be transitioned (we don't know which tenant to fail), but
+ // the message still goes to poison so it leaves the live queue.
+ var harness = new Harness();
+
+ var bad = harness.BuildMessage(dequeueCount: 6, bodyOverride: "{ not json at all");
+ await harness.InvokeMoveToPoisonAsync(bad);
+
+ Assert.Equal(1, harness.PoisonSendCount);
+ Assert.Equal(1, harness.MainDeleteCount);
+ // No state transition happened — nothing to assert there, but the History row is
+ // untouched.
+ Assert.Equal("InProgress", harness.Repo.History[HistoryRowKey].Status);
+ }
+
+ // ── Harness ─────────────────────────────────────────────────────────────────
+
+ private sealed class Harness
+ {
+ public TenantOffboardingWorker Worker { get; }
+ public FakeOffboardingAuditRepository Repo { get; } = new();
+ public Mock MainQueue { get; } = new();
+ public Mock PoisonQueue { get; } = new();
+ public int PoisonSendCount { get; private set; }
+ public int MainDeleteCount { get; private set; }
+
+ public Harness(bool poisonSendThrows = false)
+ {
+ SeedAudit();
+
+ // Cascade-handler dependencies. The handler is real — it talks to FakeRepo +
+ // OpsEvent + (unused) other deps. Only MarkEnvelopeFailedFromPoisonAsync is
+ // exercised, so most deps are noise.
+ var handler = BuildHandler();
+
+ PoisonQueue.Setup(q => q.SendMessageAsync(It.IsAny(), It.IsAny()))
+ .Returns((_, _) =>
+ {
+ PoisonSendCount++;
+ if (poisonSendThrows) throw new InvalidOperationException("simulated poison-queue 503");
+ return Task.FromResult(Response.FromValue(
+ QueuesModelFactory.SendReceipt(
+ messageId: Guid.NewGuid().ToString(),
+ insertionTime: DateTimeOffset.UtcNow,
+ expirationTime: DateTimeOffset.UtcNow.AddDays(7),
+ popReceipt: "fake",
+ timeNextVisible: DateTimeOffset.UtcNow),
+ new Mock().Object));
+ });
+
+ MainQueue.Setup(q => q.DeleteMessageAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns((_, _, _) =>
+ {
+ MainDeleteCount++;
+ return Task.FromResult(new Mock().Object);
+ });
+
+ Worker = new TenantOffboardingWorker(
+ MainQueue.Object, PoisonQueue.Object, handler,
+ BuildOpsService(),
+ NullLogger.Instance);
+ }
+
+ public QueueMessage BuildMessage(int dequeueCount, string? bodyOverride = null)
+ {
+ var envelope = new TenantOffboardingEnvelope
+ {
+ TenantId = TenantId,
+ HistoryPartitionKey = Constants.OffboardingPartitionKeys.History,
+ HistoryRowKey = HistoryRowKey,
+ InitiatedBy = "alice@contoso.invalid",
+ InitiatedAt = DateTime.UtcNow.AddMinutes(-1),
+ EnqueuedAt = DateTime.UtcNow,
+ DrainPollCount = 0,
+ };
+ return QueuesModelFactory.QueueMessage(
+ messageId: Guid.NewGuid().ToString(),
+ popReceipt: "fake-receipt",
+ body: new BinaryData(bodyOverride ?? JsonConvert.SerializeObject(envelope)),
+ dequeueCount: dequeueCount);
+ }
+
+ public Task InvokeMoveToPoisonAsync(QueueMessage msg)
+ {
+ // MoveToPoisonAsync is private — invoke via reflection. Avoids spinning up the
+ // full ExecuteAsync poll loop with a real timer.
+ var method = typeof(TenantOffboardingWorker)
+ .GetMethod("MoveToPoisonAsync", BindingFlags.NonPublic | BindingFlags.Instance);
+ Assert.NotNull(method);
+ return (Task)method!.Invoke(Worker, new object[] { msg, default(CancellationToken) })!;
+ }
+
+ private void SeedAudit()
+ {
+ Repo.History[HistoryRowKey] = new OffboardingHistoryEntry
+ {
+ PartitionKey = Constants.OffboardingPartitionKeys.History,
+ RowKey = HistoryRowKey,
+ TenantId = TenantId,
+ DomainName = "contoso.invalid",
+ InitiatedBy = "alice@contoso.invalid",
+ OffboardedAt = DateTime.UtcNow.AddMinutes(-1),
+ Status = "InProgress",
+ };
+ Repo.Markers[TenantId] = new OffboardingMarkerEntry
+ {
+ PartitionKey = Constants.OffboardingPartitionKeys.Marker,
+ RowKey = TenantId,
+ TenantId = TenantId,
+ OffboardingHistoryRowKey = HistoryRowKey,
+ InitiatedAt = DateTime.UtcNow.AddMinutes(-1),
+ InitiatedBy = "alice@contoso.invalid",
+ Status = "InProgress",
+ };
+ Repo.Pointers[TenantId] = (new OffboardingByTenantPointer
+ {
+ PartitionKey = Constants.OffboardingPartitionKeys.ByTenant,
+ RowKey = TenantId,
+ TenantId = TenantId,
+ LatestHistoryRowKey = HistoryRowKey,
+ LatestStatus = "InProgress",
+ LatestUpdatedAt = DateTime.UtcNow,
+ OffboardCount = 1,
+ }, "\"0xFAKE_PTR_1\"");
+ }
+
+ private TenantOffboardingHandler BuildHandler()
+ {
+ // Wire a real Handler so MarkEnvelopeFailedFromPoisonAsync uses FailAsync which
+ // touches the FakeRepo. The "transition throws" branch is exercised by setting
+ // Repo.ThrowOnNextHistoryUpsert in the test.
+ var unusedTableStorage = new Mock(
+ Mock.Of(),
+ NullLogger.Instance).Object;
+
+ var enumerator = new Mock(new Mock().Object);
+
+ return new NoopCustomsArchivingPoisonHandler(
+ Repo,
+ enumerator.Object,
+ Mock.Of(),
+ new FakeOffboardingExpectationsStore(),
+ Mock.Of(),
+ new NoopSafeWipeService(),
+ unusedTableStorage,
+ Mock.Of(),
+ Mock.Of(),
+ BuildOpsService(),
+ Mock.Of(),
+ NullLogger.Instance);
+ }
+
+ private sealed class NoopCustomsArchivingPoisonHandler : TenantOffboardingHandler
+ {
+ public NoopCustomsArchivingPoisonHandler(
+ IOffboardingAuditRepository a, OffboardingSessionEnumerator e, ISessionDeletionEnqueuer c,
+ IOffboardingExpectationsStore exp, IDeletionProgressDrainProbe d, SafeWipeService sw,
+ TableStorageService s, IMaintenanceRepository m, ITenantOffboardingEnqueuer re,
+ OpsEventService o, ITenantCustomsArchiveRepository ca, ILogger log)
+ : base(a, e, c, exp, d, sw, s, m, re, o, ca, log) { }
+
+ internal override Task ArchiveAndWipeRulesTableAsync(
+ string tableName, string tenantId, string historyRowKey, CancellationToken ct)
+ => Task.CompletedTask;
+ }
+
+ private static OpsEventService BuildOpsService()
+ {
+ var opsRepo = new Mock();
+ opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny())).Returns(Task.CompletedTask);
+ return new OpsEventService(opsRepo.Object, NullLogger.Instance, BuildAlertDispatch());
+ }
+
+ private static OpsAlertDispatchService BuildAlertDispatch()
+ {
+ var memCache = new MemoryCache(new MemoryCacheOptions());
+ var adminConfig = new Mock(
+ Mock.Of(), NullLogger.Instance, memCache);
+ return new OpsAlertDispatchService(
+ adminConfig.Object,
+ new TelegramNotificationService(new HttpClient(), Mock.Of(),
+ NullLogger.Instance),
+ new WebhookNotificationService(new HttpClient(),
+ NullLogger.Instance),
+ NullLogger.Instance);
+ }
+ }
+
+ private sealed class NoopSafeWipeService : SafeWipeService
+ {
+ public NoopSafeWipeService() : base(
+ new TableStorageService(Mock.Of(), NullLogger.Instance),
+ new BlobStorageService(new BlobServiceClient("UseDevelopmentStorage=true"),
+ NullLogger.Instance, usesManagedIdentity: false),
+ NullLogger.Instance) { }
+ public override Task WipeByExactPartitionAsync(string t, string i, CancellationToken c = default) => Task.FromResult(0);
+ public override Task WipeByCompositePartitionRangeAsync(string t, string i, CancellationToken c = default) => Task.FromResult(0);
+ public override Task WipeByDiscriminatorAndTenantPropertyAsync(string t, string d, string i, CancellationToken c = default) => Task.FromResult(0);
+ public override Task WipeByTenantIdPropertyAsync(string t, string i, CancellationToken c = default) => Task.FromResult(0);
+ public override Task WipeBlobsByTenantPrefixAsync(string c, string i, CancellationToken ct = default) => Task.FromResult(0);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/OffboardingExpectationsStoreTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/OffboardingExpectationsStoreTests.cs
new file mode 100644
index 00000000..cbbdb663
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/OffboardingExpectationsStoreTests.cs
@@ -0,0 +1,370 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Functions.Services.Offboarding;
+using AutopilotMonitor.Shared.Models.Offboarding;
+using Azure;
+using Azure.Storage.Blobs;
+using Azure.Storage.Blobs.Models;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Rev-6-F1 + Rev-7-F2 + Rev-8-F1: full coverage of the blob-IO contracts on
+///