Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/TableLifecycleBucketTests.cs
T
Oliver Kieselbach 88cd29d3b9 backend: lifecycle completeness nets + session-scope point-read + server-side dashboard search (audit P5/P6)
P5.1 Table lifecycle buckets: TableLifecycleBucketTests classifies every
TableNames constant (offboarding buckets via reflection, per-session set from a
real DeletionManifestBuilder run, reviewed KeptByDesign list). Fixes found by
the audit: UsageMetrics offboarding wipe matched 0 rows (PK=date/RK=tenantId)
-> new SafeWipe Variant D (RowKey-anchored, verify protects the "global" row);
PlatformStats tenant partition + ScriptNameCache never wiped -> tenant-PK bucket.

P5.2 SessionsIndex field manifest: SessionIndexFieldManifest (32 always / 18
conditional / 4 primary-only) pinned bidirectionally against
BuildSessionIndexEntity and MapToSessionSummary; full rebuilds now
TableUpdateMode.Replace (heals stale conditional columns); merge sites warn on
non-manifest keys; force-path now mirrors StalledAt write + clear heals.

P5.3/P6.1 Session scope resolution: ResolveSessionScopeAsync (RequestContext)
replaces the 16-site copy-paste fallback; ResolveSessionTenantIdAsync does a
point-read on the new SessionTenantLookup table (PK=sessionId) with legacy
index-scan fallback + self-heal, replacing the cross-partition scan; the
"Count == 0 -> rescan" heuristics are gone. Write paths gate cross-tenant
resolution on IsGlobalAdmin. RecomputeTriggerGate enforces the decided
read-only semantics API-side: reanalyze/rescan now 403 for Viewer/GlobalReader/
cross-tenant tenant roles. Lookup rows ride the deletion cascade (step 22).

P6.2 Server-side dashboard search: q= on search/sessions (+global) matching
exactly the client-side searchable fields, page-backfill loop (max 10 Azure
pages/request), q bound into the pagination fingerprint; the web dashboard's
searchAll() replaces the loadAll() full-history sweep (dedupe-merge, delegated
callers keep the legacy sweep).

P6.3 Context value memoization: TenantConfigContext + Auth/Notification/
Tenant/Theme providers (cuts the externally-triggered re-render cascade).

Verified: solution build 0W/0E; Functions.Tests 4106 (+37), DecisionCore 519,
V2.Core 2206, SummaryDialog 44; tsc/eslint clean; vitest 722 (+4). Drift
detection of both new manifest tests proven by mutation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 19:29:27 +02:00

275 lines
14 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Azure.Data.Tables;
using AutopilotMonitor.Functions.Services.Deletion;
using AutopilotMonitor.Functions.Services.Offboarding;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models.Deletion;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Lifecycle-completeness net for <see cref="Constants.TableNames"/> (Fragilitätsaudit P5.1).
/// Every table must be covered by at least one deletion lifecycle — tenant-offboarding wipe
/// (read via reflection from <see cref="TenantOffboardingHandler"/>'s bucket arrays), the
/// per-session deletion cascade (derived from a real <see cref="DeletionManifestBuilder"/>
/// manifest), or the explicit <see cref="KeptByDesign"/> list below. A new table that lands
/// in no bucket fails here with instructions; a kept-by-design table that someone starts
/// wiping (or vice versa) fails too.
///
/// NOT covered mechanically: whether a bucket's KEY SHAPE matches the table's writer
/// (the UsageMetrics bug this package fixed — PK=date/RK=tenantId sat in the exact-PK
/// bucket and the wipe matched 0 rows). When classifying a new table, read its writer's
/// <c>new TableEntity(pk, rk)</c> call before picking the bucket.
/// </summary>
public class TableLifecycleBucketTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string SessionId = "22222222-2222-2222-2222-222222222222";
/// <summary>
/// Tables that intentionally survive both tenant offboarding and session deletion.
/// Each entry needs a reason — this list is the reviewed exception set, not a dumping
/// ground for "forgot to classify".
/// </summary>
private static readonly Dictionary<string, string> KeptByDesign = new(StringComparer.Ordinal)
{
[Constants.TableNames.AdminConfiguration] = "single platform 'GlobalConfig' partition, no tenant rows",
[Constants.TableNames.GlobalAdmins] = "platform operator identities",
[Constants.TableNames.McpUsers] = "platform MCP identities (PK='McpUsers', RK=UPN)",
[Constants.TableNames.TenantGroupAssignments] = "per-UPN rows without tenant anchor; offboarding never touches it (see Constants doc)",
[Constants.TableNames.PreviewConfig] = "global TelegramBot config row",
[Constants.TableNames.Feedback] = "product feedback deliberately survives offboarding (TableFeedbackRepositoryTests contract)",
[Constants.TableNames.BlockedVersions] = "platform-wide agent version blocks",
[Constants.TableNames.GlobalNotifications] = "GA notifications; time-retention only",
[Constants.TableNames.VulnerabilityCache] = "CPE/CVE cache without tenant dimension",
[Constants.TableNames.OpsEvents] = "platform ops stream; time-retention only",
[Constants.TableNames.ImeVersionHistory] = "permanent platform archive by design",
[Constants.TableNames.RuleStats] = "per-rule daily counters; time-retention only",
[Constants.TableNames.OffboardingAudit] = "the audit trail OF the offboarding itself",
[Constants.TableNames.TenantOffboardingCustomsArchive] = "operator-driven cleanup by design",
[Constants.TableNames.BackupJobs] = "platform backup job log (PK='BackupJobs'); 365d retention",
};
/// <summary>
/// Wipes the offboarding handler performs OUTSIDE its bucket arrays (one-off calls in
/// HandleAsync). Reflection over the arrays cannot see these; keep in sync with the
/// handler's phase code when adding one.
/// </summary>
private static readonly string[] OneOffOffboardingWipes =
{
// Phase 2.F-final: deleted LAST via SafeWipe after everything else completed.
Constants.TableNames.TenantConfiguration,
};
/// <summary>
/// Tables allowed in MORE than one offboarding bucket. BootstrapSessions has main rows
/// under PK=tenantId AND CodeLookup rows under the discriminator PK.
/// </summary>
private static readonly string[] AllowedMultiBucket =
{
Constants.TableNames.BootstrapSessions,
};
[Fact]
public void TableNamesConstants_SetEquals_TableNamesAll()
{
var constants = ConstStrings(typeof(Constants.TableNames));
var all = Constants.TableNames.All;
var missingFromAll = constants.Except(all, StringComparer.Ordinal).ToList();
var orphansInAll = all.Except(constants, StringComparer.Ordinal).ToList();
Assert.True(missingFromAll.Count == 0,
"TableNames constants missing from TableNames.All (table will never be created at startup):\n - "
+ string.Join("\n - ", missingFromAll));
Assert.True(orphansInAll.Count == 0,
"TableNames.All entries without a matching constant:\n - " + string.Join("\n - ", orphansInAll));
}
[Fact]
public async Task EveryTable_HasALifecycleClassification()
{
var allTables = ConstStrings(typeof(Constants.TableNames)).ToHashSet(StringComparer.Ordinal);
var offboardWiped = OffboardingBucketUnion();
var perSession = await PerSessionCascadeTablesAsync();
var unclassified = allTables
.Except(offboardWiped, StringComparer.Ordinal)
.Except(perSession, StringComparer.Ordinal)
.Except(KeptByDesign.Keys, StringComparer.Ordinal)
.OrderBy(t => t, StringComparer.Ordinal)
.ToList();
Assert.True(unclassified.Count == 0,
"Tables without any lifecycle classification. Either add them to an offboarding wipe bucket "
+ "(TenantOffboardingHandler — check the writer's PK/RK shape first!), to the per-session "
+ "deletion manifest (DeletionManifestBuilder), or to KeptByDesign in this test WITH a reason:\n - "
+ string.Join("\n - ", unclassified));
}
[Fact]
public async Task KeptByDesign_IsDisjointFromEveryWipePath()
{
var offboardWiped = OffboardingBucketUnion();
var perSession = await PerSessionCascadeTablesAsync();
var conflicts = KeptByDesign.Keys
.Where(t => offboardWiped.Contains(t) || perSession.Contains(t))
.ToList();
Assert.True(conflicts.Count == 0,
"Tables listed as kept-by-design but ALSO wiped by offboarding or the session cascade — "
+ "one of the two claims is wrong:\n - " + string.Join("\n - ", conflicts));
}
[Fact]
public void EveryOffboardingBucketEntry_IsAKnownTableName()
{
var known = ConstStrings(typeof(Constants.TableNames)).ToHashSet(StringComparer.Ordinal);
var orphans = OffboardingBuckets()
.SelectMany(b => b.Tables.Select(t => $"{b.Name}: {t}"))
.Where(entry => !known.Contains(entry.Split(": ", 2)[1]))
.ToList();
Assert.True(orphans.Count == 0,
"Offboarding bucket entries that are not TableNames constants:\n - " + string.Join("\n - ", orphans));
}
[Fact]
public void OffboardingBuckets_AreDisjoint_ExceptDocumentedMultiMembership()
{
var buckets = OffboardingBuckets().ToList();
var violations = new List<string>();
for (var i = 0; i < buckets.Count; i++)
{
for (var j = i + 1; j < buckets.Count; j++)
{
var overlap = buckets[i].Tables
.Intersect(buckets[j].Tables, StringComparer.Ordinal)
.Except(AllowedMultiBucket, StringComparer.Ordinal)
.ToList();
violations.AddRange(overlap.Select(t => $"{t} (in {buckets[i].Name} AND {buckets[j].Name})"));
}
}
Assert.True(violations.Count == 0,
"Tables in more than one offboarding wipe bucket without a documented exception — "
+ "a double wipe with different key anchors usually means one of the two is wrong:\n - "
+ string.Join("\n - ", violations));
}
// ── Bucket derivation ────────────────────────────────────────────────────────
private sealed record Bucket(string Name, IReadOnlyList<string> Tables);
private static IEnumerable<Bucket> OffboardingBuckets()
{
yield return new Bucket("TenantPartitionTables", HandlerArray("TenantPartitionTables"));
yield return new Bucket("CompositePartitionTables", HandlerArray("CompositePartitionTables"));
yield return new Bucket("RowKeyTables", HandlerArray("RowKeyTables"));
yield return new Bucket("PropertyOnlyTables", HandlerArray("PropertyOnlyTables"));
yield return new Bucket("DiscriminatorTables",
HandlerTupleArray<(string Table, string Discriminator)>("DiscriminatorTables").Select(t => t.Table).ToList());
yield return new Bucket("ArchivedRuleTables",
HandlerTupleArray<(string Table, string Field)>("ArchivedRuleTables").Select(t => t.Table).ToList());
yield return new Bucket("OneOffOffboardingWipes", OneOffOffboardingWipes);
}
private static HashSet<string> OffboardingBucketUnion()
=> OffboardingBuckets().SelectMany(b => b.Tables).ToHashSet(StringComparer.Ordinal);
private static string[] HandlerArray(string fieldName)
=> (string[])HandlerField(fieldName).GetValue(null)!;
private static T[] HandlerTupleArray<T>(string fieldName)
=> (T[])HandlerField(fieldName).GetValue(null)!;
private static FieldInfo HandlerField(string fieldName)
=> typeof(TenantOffboardingHandler).GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException(
$"TenantOffboardingHandler.{fieldName} not found — bucket array renamed? Update this test's derivation.");
/// <summary>
/// Derives the per-session cascade table set by building a REAL deletion manifest against
/// an empty-inventory fake, so new builder steps are picked up automatically. Synthetic
/// steps map back to their tables: Tombstone → Sessions + SessionsIndex,
/// SoftwareInventoryDecrement → SoftwareInventory.
/// </summary>
private static async Task<HashSet<string>> PerSessionCascadeTablesAsync()
{
var builder = new DeletionManifestBuilder(new EmptyInventoryReader(), NullLogger<DeletionManifestBuilder>.Instance);
var manifest = await builder.BuildAsync(
TenantId, SessionId, "lifecycle_bucket_test",
new DeletionActor { Type = "admin", Actor = "alice@contoso.com" },
new DeletionRetentionContext { TenantRetentionDays = 90 });
var tables = manifest.Steps
.Where(s => !string.IsNullOrEmpty(s.Table))
.Select(s => s.Table!)
.ToHashSet(StringComparer.Ordinal);
if (manifest.Steps.Any(s => s.Step == DeletionStepNames.Tombstone))
{
tables.Add(Constants.TableNames.SessionsIndex);
tables.Add(Constants.TableNames.Sessions);
}
if (manifest.Steps.Any(s => s.Step == DeletionStepNames.SoftwareInventoryDecrement))
{
tables.Add(Constants.TableNames.SoftwareInventory);
}
// The inventory pair (steps 20/21) is only emitted when a contributions side-row
// exists; the fake provides one so the derivation sees the complete step list.
Assert.Contains(Constants.TableNames.SessionInventoryContributions, tables);
return tables;
}
private static string[] ConstStrings(Type constClass)
=> constClass.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
.Select(f => (string)f.GetRawConstantValue()!)
.ToArray();
// ── Fake inventory reader ────────────────────────────────────────────────────
private sealed class EmptyInventoryReader : ISessionDeletionInventoryReader
{
private const string IndexRowKey = "0000000000000000001_" + SessionId;
public Task<TableEntity?> GetSessionRowAsync(string tenantId, string sessionId, CancellationToken cancellationToken = default)
=> Task.FromResult<TableEntity?>(new TableEntity(tenantId, sessionId) { ["IndexRowKey"] = IndexRowKey });
public Task<TableEntity?> GetSessionsIndexRowAsync(string tenantId, string indexRowKey, CancellationToken cancellationToken = default)
=> Task.FromResult<TableEntity?>(new TableEntity(tenantId, indexRowKey) { ["SessionId"] = SessionId });
public async IAsyncEnumerable<TableEntity> QueryAsync(string tableName, string filter, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
yield break;
}
public Task<TableEntity?> GetEntityOrNullAsync(string tableName, string partitionKey, string rowKey, CancellationToken cancellationToken = default)
{
// Provide the SessionInventoryContributions side-row so steps 20/21 are emitted.
if (tableName == Constants.TableNames.SessionInventoryContributions)
{
return Task.FromResult<TableEntity?>(new TableEntity(partitionKey, rowKey));
}
return Task.FromResult<TableEntity?>(null);
}
public Task<TableEntity?> GetActiveSessionTombstoneAsync(string tenantId, string sessionId, CancellationToken cancellationToken = default)
=> Task.FromResult<TableEntity?>(null);
}
}