Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/FakeCustomsArchiveRepository.cs
T
Oliver Kieselbach 206388da99 feat(backend,web): tenant offboarding async cascade with customs archive
Replace synchronous DeleteAllTenantDataAsync with a queued async cascade
that wipes all 24 tenant-scoped tables safely, archives custom rules,
and survives crashes at every step.

Phase 1 (TenantOffboardFunction, ~200ms sync):
  - Write History / ByTenantPointer / Marker rows (OffboardingAudit table)
  - Flip TenantConfiguration.Disabled=true (the active auth gate)
  - Enqueue with 6 min visibility-delay (cache-drain barrier =
    TenantConfigurationService.CacheDuration 5 min + 1 min safety)
  - Return 202 with EarliestProcessingAt for the UI countdown

Phase 2 (TenantOffboardingWorker + Handler, async after drain):
  - Enumerate sessions, per-session cascade-enqueue, persist
    Expectations blob (separate offboarding-state container)
  - Drain probe against per-session progress blobs; self re-enqueue
    up to 60x at 2 min visibility-delay
  - 2.D-archive: GatherRules / AnalyzeRules / ImeLogPatterns moved
    to TenantOffboardingCustomsArchive table (3-iter cap + verify)
  - 2.D-wipe: SafeWipe over all tenant tables (4 variants: exact-PK,
    composite-PK range, discriminator+TenantId, property-only) with
    fetch+verify+delete pattern + SafeWipeVerificationException
  - 2.E: blob-prefix wipe deletion-manifests/{tenantId}/
  - 2.D-pass-2: re-wipe AuditLogs (defense-in-depth)
  - 2.G: side-effects FIRST (Marker, Pointer, blob, audit, OpsEvent)
    then History=Completed LAST (DrainCompletedAt as resume anchor)
  - 2.F-final: delete TenantConfiguration row LAST so self-service
    re-onboarding (next /api/auth/me auto-creates default) works

Defense-in-depth:
  - OffboardingMarkerCleanupFunction (2h timer): sweeps Completed
    markers after 15 min grace, conditional TenantConfiguration sweep
    (only when row is still the offboarding tombstone — preserves
    user-driven self-service re-onboards)
  - Worker poison path: durable Failed state MUST land before queue
    moves; transient failure leaves message visible for retry
  - SessionDeletionHandler reorder: Audit + SignalR notify BEFORE
    CompletedAt write so the drain predicate is correct

Web UI:
  - /settings danger zone: typed "OFFBOARD" confirmation dialog,
    post-202 drain-barrier banner with minute-based countdown, hint
    "you can sign out and close this tab — deletion runs in
    background", silent auto-logout when countdown elapses
  - /admin/customs-archive (GA only): list runs + inspect entries +
    typed "DELETE" confirmation modal for run/entry deletion
  - OpsAlertRulesSection: TenantOffboardingFailed event registered
  - lib/api.ts: shared CustomsArchive* response types

Tests: 100+ new tests covering drain contract, handler ordering,
worker poison path, ETag-CAS, SafeWipe variants, OffboardingFilters
prefix-bleed safety, MarkerCleanup conditional sweep, Bootstrap
auth-disabled-tenant gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 23:06:51 +02:00

106 lines
4.0 KiB
C#

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;
/// <summary>
/// In-memory <see cref="ITenantCustomsArchiveRepository"/> for PR3.B handler + endpoint tests.
/// Idempotent upsert keyed by (PK, RK); supports the four query shapes the admin API uses.
/// </summary>
internal sealed class FakeCustomsArchiveRepository : ITenantCustomsArchiveRepository
{
private readonly Dictionary<string, TenantOffboardingCustomsArchiveEntry> _store
= new(System.StringComparer.Ordinal);
public IReadOnlyDictionary<string, TenantOffboardingCustomsArchiveEntry> 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<int> 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<TenantOffboardingCustomsArchiveEntry> 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<TenantOffboardingCustomsArchiveEntry> 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<TenantOffboardingCustomsArchiveEntry> QueryAllAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
foreach (var entry in _store.Values)
{
yield return Clone(entry);
}
await Task.CompletedTask;
}
public Task<TenantOffboardingCustomsArchiveEntry?> TryGetEntryAsync(
string partitionKey, string rowKey, CancellationToken ct = default)
{
_store.TryGetValue(Key(partitionKey, rowKey), out var entry);
return Task.FromResult<TenantOffboardingCustomsArchiveEntry?>(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<int> 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,
};
}