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

68 lines
2.7 KiB
C#

using AutopilotMonitor.Functions.DataAccess.TableStorage;
namespace AutopilotMonitor.Functions.Tests.Offboarding;
/// <summary>
/// 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.
/// </summary>
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<ArgumentException>(() =>
TableTenantCustomsArchiveRepository.DecodeOriginalRowKey(rowKey, "AnalyzeRules"));
}
}