mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-26 05:29:43 +00:00
206388da99
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>
68 lines
2.7 KiB
C#
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"));
|
|
}
|
|
}
|