mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
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>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
---
|
||||
type: concept
|
||||
title: Lifecycle Manifests & Session Scope Resolution
|
||||
description: The enforced completeness nets added by the fragility-audit P5/P6 remediation — table lifecycle buckets, the SessionsIndex field manifest, the single session-scope resolver with its SessionTenantLookup point-read, and the server-side dashboard free-text search.
|
||||
resource: backend
|
||||
tags: [lifecycle, offboarding, sessionsindex, manifest, scope-resolution, search, performance]
|
||||
timestamp: 2026-08-13
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
Four mechanisms replace hand-synchronized copies with a declared source plus tests that fail on drift.
|
||||
|
||||
## 1. Table lifecycle buckets
|
||||
|
||||
Every table in `Constants.TableNames` must belong to at least one deletion lifecycle:
|
||||
|
||||
| Bucket | Mechanism | Source of truth |
|
||||
|---|---|---|
|
||||
| Tenant-offboarding wipe | `TenantOffboardingHandler` bucket arrays (`TenantPartitionTables`, `CompositePartitionTables`, `DiscriminatorTables`, `PropertyOnlyTables`, `RowKeyTables`, `ArchivedRuleTables`) + the one-off `TenantConfiguration` final delete | handler arrays (read via reflection by the test) |
|
||||
| Per-session cascade | `DeletionManifestBuilder.BuildAsync` step list (incl. the Final tombstone → Sessions + SessionsIndex) | a real manifest built against an empty-inventory fake |
|
||||
| Kept by design | reviewed exception list with per-table reasons | `TableLifecycleBucketTests.KeptByDesign` |
|
||||
|
||||
`TableLifecycleBucketTests` (Functions.Tests) asserts: constants set-equal `TableNames.All`, no table unclassified, kept-by-design disjoint from every wipe path, bucket entries are real constants, offboarding buckets disjoint except documented multi-membership (`BootstrapSessions`).
|
||||
|
||||
**Not covered mechanically:** whether a bucket's key shape matches the table's writer. That was the UsageMetrics bug (PK=date, RK=tenantId sat in the exact-PK bucket; the wipe matched 0 rows) — SafeWipe **Variant D** (`WipeByRowKeyAsync`, `RowKey eq '{tenantId}'`) now covers RowKey-anchored tables. Read the writer's `new TableEntity(pk, rk)` before classifying a new table.
|
||||
|
||||
## 2. SessionsIndex field manifest
|
||||
|
||||
`SessionIndexFieldManifest` (Functions/Services) declares the full-mirror column set: `AlwaysProjected` (32, written with defaults on every rebuild), `ConditionallyProjected` (18, written only when present), `PrimaryOnly` (4 — ServerActions + deletion-CAS fields that never touch the index). `SessionIndexFieldManifestTests` pins `BuildSessionIndexEntity` against it bidirectionally (full-row fixture built from a test-local sample table, NOT from the manifest — deriving the fixture from the manifest would make the check circular), pins that every manifest field influences `MapToSessionSummary`, and pins the rebuild's `TableUpdateMode.Replace` (a merge-mode upsert could never clear a conditional column the primary blanked). `MergeSessionIndexAsync` logs a warning for merged keys outside the manifest (fail-soft drift guard).
|
||||
|
||||
## 3. Session scope resolution (one implementation)
|
||||
|
||||
`RequestContextExtensions.ResolveSessionScopeAsync(ctx, sessionRepo, sessionId, requireGlobalAdmin)` is the single implementation of the former 15-site copy-paste fallback ("which tenant owns this session?"):
|
||||
|
||||
- Non-global callers always keep the middleware-validated `TargetTenantId`.
|
||||
- Global-scope callers resolve via `ISessionRepository.ResolveSessionTenantIdAsync` — a point-read on **SessionTenantLookup** (PK=sessionId, RK="tenant") with a legacy cross-partition SessionsIndex scan fallback that self-heals the lookup row, so the scan is paid at most once per pre-table session.
|
||||
- `requireGlobalAdmin: true` on WRITE paths gates the cross-tenant reach on `IsGlobalAdmin` so a read-only Global Reader can never steer a write into a foreign tenant.
|
||||
- Unknown session → `TargetTenantId`, leaving each endpoint's own not-found semantics (404 / empty 200 / 400) in charge.
|
||||
- Delegated (MSP) callers deliberately get NO fallback — extending it to `AllowedTenantIds` is a pending user decision.
|
||||
|
||||
The lookup row is written at session registration (fail-soft), deleted by the cascade one step before the tombstone, and rebuildable from Sessions (not in the critical-backup set). The former lazy triggers (`events.Count == 0` → scan) are gone: empty-but-owned results no longer pay a table scan, and endpoints resolve upfront.
|
||||
|
||||
Related enforcement added with the migration: `RecomputeTriggerGate` — `?reanalyze=true` and `?rescan=true` on the MemberRead session routes are actions (delete + rewrite stored results) and now 403 for Viewer / Global Reader / cross-tenant tenant roles (Global Admin or own-tenant Admin/Operator only).
|
||||
|
||||
## 4. Server-side dashboard search
|
||||
|
||||
`q=` on `/api/search/sessions` + `/api/global/search/sessions`: case-insensitive substring over exactly the fields the dashboard's client-side filter searches (device name, serial, manufacturer, model, status, sessionId, geo, agent version, OS fields — NOT the client-derived date/duration/blocked tokens; a server-only field would produce ghost results the client filters back out). Azure Tables has no substring operator, so the scan path backfills whole Azure pages until it has `pageSize` matches (max 10 pages per request, continuation at a page boundary — gap-free). `q` is bound into the pagination fingerprint. The web dashboard's `searchAll()` (useDashboardSessions) follows nextLink up to 10 requests / 50 matches and dedupe-merges results into the loaded list (`mergeSessionsById`), replacing the former `loadAll()` walk that downloaded the entire session history to search it client-side. Delegated callers in cross-tenant mode keep the legacy full sweep (the global search route has no delegated tier).
|
||||
|
||||
# Examples
|
||||
|
||||
Adding a new table: classify it in one lifecycle path before `TableLifecycleBucketTests` lets it ship — offboarding bucket (check the writer's PK/RK shape first), deletion-manifest step, or `KeptByDesign` with a reason.
|
||||
|
||||
Adding a mirrored SessionsIndex field: manifest entry + `BuildSessionIndexEntity` write + `MapToSessionSummary` read + a sample value in the test — any missing step is red.
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Backend/AutopilotMonitor.Functions.Tests/TableLifecycleBucketTests.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions/Services/SessionIndexFieldManifest.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions/Helpers/RequestContext.cs` (`ResolveSessionScopeAsync`)
|
||||
- `src/Backend/AutopilotMonitor.Functions/Services/Offboarding/SafeWipeService.cs` (Variant D)
|
||||
- `src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.AgentApi.cs` (`MatchesFreeText`, backfill loop)
|
||||
@@ -44,6 +44,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else.
|
||||
* [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade.
|
||||
* [Session Annotations](backend/session-annotations.md) - Role-separated human verdicts on sessions (operator / tenantadmin / globaladmin lanes, one row each): the per-lane write matrix with own-tenant binding, the platform-internal GA lane, the fired-rule-id snapshot that makes annotations rule-quality labels, and the portal card + MCP list/annotate surfaces.
|
||||
* [Lifecycle Manifests & Session Scope Resolution](backend/lifecycle-manifests-and-session-scope.md) - The fragility-audit P5/P6 completeness nets: every table classified into a deletion lifecycle (offboarding buckets incl. the RowKey-anchored SafeWipe Variant D, per-session cascade, kept-by-design), the SessionsIndex field manifest with bidirectional builder/reader pinning, the single ResolveSessionScopeAsync helper backed by the SessionTenantLookup point-read, and the server-side dashboard free-text search (q=) with its page-backfill loop.
|
||||
|
||||
# MCP Server
|
||||
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-13 (later)
|
||||
## 2026-08-13 (evening)
|
||||
|
||||
* **Creation**: Added `backend/lifecycle-manifests-and-session-scope.md` — the fragility-audit P5/P6 completeness nets: (1) `TableLifecycleBucketTests` classifies every `TableNames` constant into offboarding-wipe / per-session-cascade / kept-by-design (fixed en route: UsageMetrics wiped via the new RowKey-anchored SafeWipe Variant D — the exact-PK wipe matched 0 rows; PlatformStats tenant partition + ScriptNameCache added to the tenant-PK bucket); (2) `SessionIndexFieldManifest` as the declared SessionsIndex column set with bidirectional builder/reader test pinning, Replace-mode full rebuilds, and a fail-soft merge-key guard; (3) `ResolveSessionScopeAsync` as the single session-scope fallback (16 former copies), backed by the new `SessionTenantLookup` point-read table (scan fallback + self-heal, cascade step before the tombstone), plus the `RecomputeTriggerGate` 403 for reanalyze/rescan by read-only roles; (4) server-side dashboard free-text search (`q=`) with page-backfill loop and fingerprint binding, replacing the client's full-history loadAll sweep.
|
||||
|
||||
* **Creation**: Added `backend/app-version-regression-radar.md` — app-version duration regression radar: per (tenant, app) the newest version's median measured install duration (nearest-rank; population Succeeded + non-skip + `HasMeasuredDuration` + no AppId collision + non-empty AppVersion) is compared against the previous version (first-seen ordering, never string sort); fires at median lift ≥2× AND ≥300s absolute with ≥10 measured installs on both sides. Episodes live as `appversionregression|{app}|{version}` tracker rows (one bell `app_version_duration_regression` + one `AppVersionDurationRegression` ops event per episode, refresh keeps `FirstNotifiedAt`, re-arm on drain/median-recovery/30d retention). Surfaces: `versionRegressions[]` + per-version `measuredInstalls`/`medianDurationSeconds`/`p95DurationSeconds` in the apps analytics responses, amber banner + duration-by-version chart on the apps detail page. Kill switch `AppVersionRegressionRadarDisabled`.
|
||||
|
||||
|
||||
@@ -75,9 +75,9 @@ public class DeletionManifestBuilderTests
|
||||
Assert.Equal(2, c["classifierVerdictsByIdLevel"]);
|
||||
Assert.Equal(11, c["signalsByKind"]);
|
||||
|
||||
// 19 cascade tables (incl. SessionTimeBreakdowns F1 PR2 + 3 SessionAnnotations lanes)
|
||||
// + tombstone = 20 steps; no inventory steps when no contributions row.
|
||||
Assert.Equal(20, manifest.Steps.Count);
|
||||
// 20 cascade tables (incl. SessionTimeBreakdowns F1 PR2, 3 SessionAnnotations lanes +
|
||||
// SessionTenantLookup P6.1) + tombstone = 21 steps; no inventory steps when no contributions row.
|
||||
Assert.Equal(21, manifest.Steps.Count);
|
||||
Assert.Equal(DeletionStepClass.Final, manifest.Steps.Last().Class);
|
||||
}
|
||||
|
||||
@@ -105,7 +105,8 @@ public class DeletionManifestBuilderTests
|
||||
.WithClassifierVerdictsByIdLevel(1, propValue: "classifier_marker")
|
||||
.WithSignalsByKind(1, propValue: "signalkind_marker")
|
||||
.WithSessionTimeBreakdown(propValue: "breakdown_marker")
|
||||
.WithSessionAnnotations(propValue: "annotation_marker"));
|
||||
.WithSessionAnnotations(propValue: "annotation_marker")
|
||||
.WithSessionTenantLookup(propValue: "lookup_marker"));
|
||||
|
||||
var manifest = await NewBuilder(reader).BuildAsync(
|
||||
TenantId, SessionId, "admin_delete",
|
||||
@@ -148,9 +149,9 @@ public class DeletionManifestBuilderTests
|
||||
new DeletionRetentionContext { TenantRetentionDays = 90 });
|
||||
|
||||
var nonTombstoneSteps = manifest.Steps.Where(s => s.Class != DeletionStepClass.Final).ToList();
|
||||
// Without a contributions row, neither inventory step is emitted → exactly 19 cascade-table steps
|
||||
// (16 + the 3 SessionAnnotations lane steps).
|
||||
Assert.Equal(19, nonTombstoneSteps.Count);
|
||||
// Without a contributions row, neither inventory step is emitted → exactly 20 cascade-table steps
|
||||
// (16 + the 3 SessionAnnotations lane steps + SessionTenantLookup).
|
||||
Assert.Equal(20, nonTombstoneSteps.Count);
|
||||
foreach (var step in nonTombstoneSteps)
|
||||
{
|
||||
Assert.Equal(0, step.RowCount);
|
||||
@@ -179,8 +180,8 @@ public class DeletionManifestBuilderTests
|
||||
new DeletionActor { Type = "admin", Actor = "alice@example.com" },
|
||||
new DeletionRetentionContext { TenantRetentionDays = 90 });
|
||||
|
||||
// Expect: 19 cascade + 2 inventory + 1 tombstone = 22 steps.
|
||||
Assert.Equal(22, manifest.Steps.Count);
|
||||
// Expect: 20 cascade + 2 inventory + 1 tombstone = 23 steps.
|
||||
Assert.Equal(23, manifest.Steps.Count);
|
||||
|
||||
var aggregate = manifest.Steps.Single(s => s.Class == DeletionStepClass.Aggregate);
|
||||
Assert.Equal(DeletionStepNames.SoftwareInventoryDecrement, aggregate.Step);
|
||||
@@ -646,6 +647,7 @@ public class DeletionManifestBuilderTests
|
||||
private TableEntity? _eventSessionIndex;
|
||||
private TableEntity? _sessionTimeBreakdown;
|
||||
private TableEntity? _sessionInventoryContributions;
|
||||
private TableEntity? _sessionTenantLookup;
|
||||
private readonly Dictionary<string, TableEntity> _sessionAnnotations = new(StringComparer.Ordinal);
|
||||
private string _sessionsIndexRowKey = "INDEX_" + SessionId;
|
||||
|
||||
@@ -801,6 +803,17 @@ public class DeletionManifestBuilderTests
|
||||
return this;
|
||||
}
|
||||
|
||||
public ReaderSeed WithSessionTenantLookup(string propValue = "lookup_marker")
|
||||
{
|
||||
_sessionTenantLookup = new TableEntity(SessionId, "tenant")
|
||||
{
|
||||
["TenantId"] = TenantId,
|
||||
["Marker"] = propValue,
|
||||
};
|
||||
_sessionTenantLookup.ETag = new ETag("0xLOOKUP");
|
||||
return this;
|
||||
}
|
||||
|
||||
public ISessionDeletionInventoryReader Build()
|
||||
{
|
||||
var reader = new Mock<ISessionDeletionInventoryReader>();
|
||||
@@ -833,6 +846,8 @@ public class DeletionManifestBuilderTests
|
||||
.ReturnsAsync(_sessionTimeBreakdown);
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionInventoryContributions, TenantId, SessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_sessionInventoryContributions);
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionTenantLookup, SessionId, "tenant", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_sessionTenantLookup);
|
||||
foreach (var (lane, row) in _sessionAnnotations)
|
||||
{
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionAnnotations, TenantId, $"{SessionId}_{lane}", It.IsAny<CancellationToken>()))
|
||||
|
||||
+6
@@ -640,6 +640,12 @@ public class TenantOffboardingHandlerDrainContractTests
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public override Task<int> WipeByRowKeyAsync(string tableName, string normalizedTenantId, CancellationToken ct = default)
|
||||
{
|
||||
WipeCallCount++;
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
public override Task<int> WipeBlobsByTenantPrefixAsync(string containerName, string normalizedTenantId, CancellationToken ct = default)
|
||||
{
|
||||
WipeCallCount++;
|
||||
|
||||
+30
@@ -293,6 +293,33 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
Assert.Contains(Constants.TableNames.ConfigurationBackups, harness.SafeWipeProbe.ExactPartitionWipes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostDrain_RowKeyWipe_CoversUsageMetrics()
|
||||
{
|
||||
// UsageMetrics rows are PK=date, RK=tenantId — the former exact-PK wipe matched 0
|
||||
// rows, so per-tenant snapshots survived offboarding. The RowKey-anchored Variant D
|
||||
// wipe is their only tenant-lifecycle deletion.
|
||||
var harness = Harness.New();
|
||||
|
||||
await harness.Sut.HandleAsync(harness.Envelope());
|
||||
|
||||
Assert.Contains(Constants.TableNames.UsageMetrics, harness.SafeWipeProbe.RowKeyWipes);
|
||||
Assert.DoesNotContain(Constants.TableNames.UsageMetrics, harness.SafeWipeProbe.ExactPartitionWipes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PostDrain_ExactPartitionWipe_CoversTenantStatsAndScriptNameCache()
|
||||
{
|
||||
// PlatformStats carries per-tenant counter rows (PK=tenantId, RK="current") and
|
||||
// ScriptNameCache is keyed PK=tenantId — both previously survived offboarding.
|
||||
var harness = Harness.New();
|
||||
|
||||
await harness.Sut.HandleAsync(harness.Envelope());
|
||||
|
||||
Assert.Contains(Constants.TableNames.PlatformStats, harness.SafeWipeProbe.ExactPartitionWipes);
|
||||
Assert.Contains(Constants.TableNames.ScriptNameCache, harness.SafeWipeProbe.ExactPartitionWipes);
|
||||
}
|
||||
|
||||
// ── Harness (copied minimal — only what these tests need) ───────────────────
|
||||
|
||||
private sealed class Harness
|
||||
@@ -442,6 +469,8 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
public List<string> PropertyOnlyWipes { get; } = new();
|
||||
/// <summary>Table names passed to the exact-partition (Variant A) wipe — lets tests assert coverage.</summary>
|
||||
public List<string> ExactPartitionWipes { get; } = new();
|
||||
/// <summary>Table names passed to the RowKey-anchored (Variant D) wipe — lets tests assert coverage.</summary>
|
||||
public List<string> RowKeyWipes { get; } = new();
|
||||
public CountingSafeWipeService() : base(
|
||||
new TableStorageService(Mock.Of<TableServiceClient>(), NullLogger<TableStorageService>.Instance),
|
||||
new BlobStorageService(new BlobServiceClient("UseDevelopmentStorage=true"),
|
||||
@@ -451,6 +480,7 @@ public class TenantOffboardingHandlerOrderingTests
|
||||
public override Task<int> WipeByCompositePartitionRangeAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
|
||||
public override Task<int> WipeByDiscriminatorAndTenantPropertyAsync(string t, string d, string i, CancellationToken c = default) { WipeCallCount++; return Task.FromResult(0); }
|
||||
public override Task<int> WipeByTenantIdPropertyAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; PropertyOnlyWipes.Add(t); return Task.FromResult(0); }
|
||||
public override Task<int> WipeByRowKeyAsync(string t, string i, CancellationToken c = default) { WipeCallCount++; RowKeyWipes.Add(t); return Task.FromResult(0); }
|
||||
public override Task<int> WipeBlobsByTenantPrefixAsync(string c, string i, CancellationToken ct = default) { WipeCallCount++; return Task.FromResult(0); }
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,14 @@ public class OffboardingFiltersTests
|
||||
Assert.Throws<ArgumentException>(() => OffboardingFilters.TenantIdProperty(input));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-a-guid")]
|
||||
[InlineData("")]
|
||||
public void RowKeyEquals_ThrowsForNonGuid(string input)
|
||||
{
|
||||
Assert.Throws<ArgumentException>(() => OffboardingFilters.RowKeyEquals(input));
|
||||
}
|
||||
|
||||
// ── Filter shapes ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
@@ -97,6 +105,17 @@ public class OffboardingFiltersTests
|
||||
Assert.DoesNotContain("PartitionKey", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RowKeyEquals_EmitsRowKeyOnlyFilter()
|
||||
{
|
||||
// Variant D anchors on the ROW key (UsageMetrics: PK=date, RK=tenantId) — the
|
||||
// filter must not constrain PartitionKey, otherwise it degenerates back into the
|
||||
// exact-PK wipe that matched 0 rows.
|
||||
var filter = OffboardingFilters.RowKeyEquals(ValidTenant);
|
||||
Assert.Equal($"RowKey eq '{ValidTenant}'", filter);
|
||||
Assert.DoesNotContain("PartitionKey", filter);
|
||||
}
|
||||
|
||||
// ── Injection neutralization (defense in depth even after EnsureValidGuid) ──
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -97,6 +97,49 @@ public class SafeWipeServiceTests
|
||||
harness.Sut.WipeByTenantIdPropertyAsync("FakeTable", TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RowKey_AbortsWhenForeignRowKeyReturned()
|
||||
{
|
||||
// Variant D anchors on the RowKey (UsageMetrics: PK=date, RK=tenantId). The "global"
|
||||
// aggregate row shares partitions with tenant rows — verify must reject anything whose
|
||||
// RowKey is not exactly the tenant id.
|
||||
var harness = new Harness(
|
||||
queriedRows: new[]
|
||||
{
|
||||
new TableEntity("2026-08-01", TenantId),
|
||||
new TableEntity("2026-08-01", "global"), // <-- must never be wiped
|
||||
});
|
||||
|
||||
await Assert.ThrowsAsync<SafeWipeVerificationException>(() =>
|
||||
harness.Sut.WipeByRowKeyAsync("FakeTable", TenantId));
|
||||
|
||||
Assert.Empty(harness.SubmittedBatches);
|
||||
Assert.Empty(harness.PerRowDeletes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RowKey_DeletesAcrossPartitionsWithETagAll()
|
||||
{
|
||||
// One row per date partition — batches are grouped by PK, so 3 partitions = 3 batches.
|
||||
var harness = new Harness(
|
||||
queriedRows: new[]
|
||||
{
|
||||
new TableEntity("2026-08-01", TenantId),
|
||||
new TableEntity("2026-08-02", TenantId),
|
||||
new TableEntity("2026-08-03", TenantId),
|
||||
});
|
||||
|
||||
var deleted = await harness.Sut.WipeByRowKeyAsync("FakeTable", TenantId);
|
||||
|
||||
Assert.Equal(3, deleted);
|
||||
Assert.Equal(3, harness.SubmittedBatches.Count);
|
||||
foreach (var action in harness.SubmittedBatches.SelectMany(b => b))
|
||||
{
|
||||
Assert.Equal(TableTransactionActionType.Delete, action.ActionType);
|
||||
Assert.Equal(ETag.All, action.Entity.ETag);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExactPartition_DeletesEveryRowWithETagAll()
|
||||
{
|
||||
@@ -175,6 +218,8 @@ public class SafeWipeServiceTests
|
||||
() => harness.Sut.WipeByDiscriminatorAndTenantPropertyAsync("t", "CodeLookup", "not-a-guid"));
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => harness.Sut.WipeByTenantIdPropertyAsync("t", "not-a-guid"));
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => harness.Sut.WipeByRowKeyAsync("t", "not-a-guid"));
|
||||
}
|
||||
|
||||
// ── Harness ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -103,7 +103,7 @@ public class SessionAnnotationFunctionsTests
|
||||
annotationRepo.Setup(r => r.GetForSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(new List<SessionAnnotation>());
|
||||
var sessionRepo = new Mock<ISessionRepository>(MockBehavior.Loose);
|
||||
sessionRepo.Setup(r => r.FindSessionTenantIdAsync(SessionId)).ReturnsAsync(TenantId);
|
||||
sessionRepo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(TenantId);
|
||||
return new GetSessionAnnotationsFunction(
|
||||
NullLogger<GetSessionAnnotationsFunction>.Instance,
|
||||
annotationRepo.Object, sessionRepo.Object);
|
||||
@@ -291,7 +291,7 @@ public class SessionAnnotationFunctionsTests
|
||||
var sessionRepo = new Mock<ISessionRepository>(MockBehavior.Loose);
|
||||
sessionRepo.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(new SessionSummary { SessionId = SessionId, TenantId = TenantId });
|
||||
sessionRepo.Setup(r => r.FindSessionTenantIdAsync(SessionId)).ReturnsAsync(TenantId);
|
||||
sessionRepo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(TenantId);
|
||||
|
||||
var ruleRepo = new Mock<IRuleRepository>(MockBehavior.Loose);
|
||||
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
|
||||
@@ -102,7 +102,7 @@ public class SessionEventsPaginationTests
|
||||
// Regression: previously the function called BuildNextLink with the raw
|
||||
// ?tenantId= query param, which is null when GA reads /api/sessions/{id}/events
|
||||
// without explicitly passing tenantId. Page 1 then bound the token to the
|
||||
// resolved tenant (via FindSessionTenantIdAsync), but the nextLink omitted
|
||||
// resolved tenant (via ResolveSessionTenantIdAsync), but the nextLink omitted
|
||||
// it — so page 2 validated against the GA's JWT tenant and the token was
|
||||
// rejected as cross_tenant. Fix: nextLink now carries the resolved tenantId
|
||||
// verbatim, and the function honours ?tenantId= on follow-up pages for GA.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Fragilitätsaudit P6.2: server-side free-text search (<c>q=</c>) for the dashboard search
|
||||
/// box, replacing the client's unbounded loadAll() sweep. The predicate must mirror the web
|
||||
/// dashboard's client-side searchable field set (minus derived-only tokens) — see the doc on
|
||||
/// <c>TableStorageService.MatchesFreeText</c>.
|
||||
/// </summary>
|
||||
public class SessionFreeTextSearchTests
|
||||
{
|
||||
private static SessionSummary Sample() => new()
|
||||
{
|
||||
SessionId = "22222222-2222-2222-2222-222222222222",
|
||||
TenantId = "11111111-1111-1111-1111-111111111111",
|
||||
DeviceName = "DESKTOP-A1B2C3",
|
||||
SerialNumber = "5CG1234XYZ",
|
||||
Manufacturer = "Contoso",
|
||||
Model = "EliteBook 840",
|
||||
Status = SessionStatus.Failed,
|
||||
GeoCountry = "DE",
|
||||
GeoRegion = "Hessen",
|
||||
GeoCity = "Frankfurt",
|
||||
AgentVersion = "2.0.1400",
|
||||
OsName = "Windows 11",
|
||||
OsBuild = "26100.1000",
|
||||
OsDisplayVersion = "24H2",
|
||||
OsEdition = "Enterprise",
|
||||
OsLanguage = "de-DE",
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData("a1b2")] // device name, case-insensitive substring
|
||||
[InlineData("5cg1234")] // serial
|
||||
[InlineData("contoso")] // manufacturer
|
||||
[InlineData("elitebook")] // model
|
||||
[InlineData("failed")] // status text
|
||||
[InlineData("2222-2222")] // sessionId fragment
|
||||
[InlineData("frankfurt")] // geo city
|
||||
[InlineData("2.0.1400")] // agent version
|
||||
[InlineData("26100")] // os build
|
||||
[InlineData("24h2")] // os display version
|
||||
public void MatchesFreeText_FindsSubstringAcrossDashboardFields(string q)
|
||||
{
|
||||
Assert.True(TableStorageService.MatchesFreeText(Sample(), q));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("tailspin")]
|
||||
[InlineData("succeeded")]
|
||||
[InlineData("99999")]
|
||||
public void MatchesFreeText_RejectsNonMatches(string q)
|
||||
{
|
||||
Assert.False(TableStorageService.MatchesFreeText(Sample(), q));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesFreeText_EmptyQuery_MatchesEverything()
|
||||
{
|
||||
Assert.True(TableStorageService.MatchesFreeText(Sample(), null));
|
||||
Assert.True(TableStorageService.MatchesFreeText(Sample(), string.Empty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fingerprint_BindsQ_SoContinuationCannotCrossQueries()
|
||||
{
|
||||
var withQ = new SessionSearchFilter { Q = "desktop" };
|
||||
var otherQ = new SessionSearchFilter { Q = "laptop" };
|
||||
var noQ = new SessionSearchFilter();
|
||||
const string caller = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
var fp1 = SearchSessionsPagination.Fingerprint("search:tenant", caller, null, withQ);
|
||||
var fp2 = SearchSessionsPagination.Fingerprint("search:tenant", caller, null, otherQ);
|
||||
var fp3 = SearchSessionsPagination.Fingerprint("search:tenant", caller, null, noQ);
|
||||
|
||||
Assert.NotEqual(fp1, fp2);
|
||||
Assert.NotEqual(fp1, fp3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Fragilitätsaudit P5.2: <see cref="SessionIndexFieldManifest"/> is the declared SessionsIndex
|
||||
/// column set; builder, reader, and merge guard are all pinned against it here.
|
||||
/// <list type="bullet">
|
||||
/// <item>Builder ↔ manifest bidirectional: a field added to <c>BuildSessionIndexEntity</c> but
|
||||
/// not the manifest fails, and vice versa.</item>
|
||||
/// <item>Reader coverage: every manifest field must influence <c>MapToSessionSummary</c> output —
|
||||
/// a projected-but-never-served column is dead weight; a served-but-not-projected one is
|
||||
/// the drift bug.</item>
|
||||
/// <item>Full-rebuild upsert is Replace-mode, so conditional fields the primary cleared do not
|
||||
/// survive as stale index columns.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public class SessionIndexFieldManifestTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string SessionId = "22222222-2222-2222-2222-222222222222";
|
||||
private const string IndexRowKey = "2516000000000000000_" + SessionId;
|
||||
|
||||
private static readonly DateTime StartedAt = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static readonly string[] SystemKeys = { "PartitionKey", "RowKey", "Timestamp", "odata.etag" };
|
||||
|
||||
/// <summary>
|
||||
/// One distinctly non-default sample value per manifest field. A new manifest field without
|
||||
/// an entry here fails the tests with a clear message — extend this map alongside the
|
||||
/// manifest. Values are typed the way the WRITER stores them (wire form).
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, object> SampleValues = new(StringComparer.Ordinal)
|
||||
{
|
||||
// SessionId sample deliberately differs from the RowKey suffix so the reader test can
|
||||
// detect that the stored property (not the RowKey fallback) is being served.
|
||||
["SessionId"] = "33333333-3333-3333-3333-333333333333",
|
||||
["SerialNumber"] = "SN-0001",
|
||||
["DeviceName"] = "PC-0001",
|
||||
["Manufacturer"] = "Contoso",
|
||||
["Model"] = "Elite-1",
|
||||
["StartedAt"] = new DateTimeOffset(StartedAt),
|
||||
["Status"] = "Failed",
|
||||
["CurrentPhase"] = 3,
|
||||
["CurrentPhaseDetail"] = "phase-detail-1",
|
||||
["EventCount"] = 42,
|
||||
["EnrollmentType"] = "v2",
|
||||
["IsPreProvisioned"] = true,
|
||||
["IsHybridJoin"] = true,
|
||||
["IsUserDriven"] = true,
|
||||
["IsSelfDeployingProfile"] = true,
|
||||
["IsCloudPc"] = true,
|
||||
["AgentVersion"] = "2.0.1400",
|
||||
["ImeAgentVersion"] = "1.2.3.4",
|
||||
["OsName"] = "Windows 11",
|
||||
["OsBuild"] = "26100.1000",
|
||||
["OsDisplayVersion"] = "24H2",
|
||||
["OsEdition"] = "Enterprise",
|
||||
["OsLanguage"] = "de-DE",
|
||||
["GeoCountry"] = "DE",
|
||||
["GeoRegion"] = "HE",
|
||||
["GeoCity"] = "Frankfurt",
|
||||
["GeoLoc"] = "50.11,8.68",
|
||||
["PlatformScriptCount"] = 2,
|
||||
["RemediationScriptCount"] = 3,
|
||||
["RebootCount"] = 4,
|
||||
["ExcessiveEventsAlerted"] = true,
|
||||
["ExcessiveEventsAutoActioned"] = true,
|
||||
["CompletedAt"] = new DateTimeOffset(StartedAt.AddMinutes(45)),
|
||||
["FailureReason"] = "esp timeout",
|
||||
["FailureSource"] = "ime",
|
||||
["ReconcileReason"] = "late completion",
|
||||
["EspSoftFailure"] = true,
|
||||
["CompletionSource"] = "esp_exit",
|
||||
["ValidatedBy"] = "AutopilotV1",
|
||||
["FailureSnapshotJson"] = "{\"x\":1}",
|
||||
["AdminMarkedAction"] = "failed",
|
||||
["DurationSeconds"] = 360,
|
||||
["DiagnosticsBlobName"] = "diag-0001.zip",
|
||||
["DiagnosticsBlobDestination"] = "Hosted",
|
||||
["LastEventAt"] = new DateTimeOffset(StartedAt.AddMinutes(44)),
|
||||
["ResumedAt"] = new DateTimeOffset(StartedAt.AddMinutes(10)),
|
||||
["StalledAt"] = new DateTimeOffset(StartedAt.AddMinutes(20)),
|
||||
["AvgApiLatencyMs"] = 513.5,
|
||||
["ApiRequestCount"] = 100,
|
||||
["ConnectionType"] = "Ethernet",
|
||||
};
|
||||
|
||||
// ── Manifest shape ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Manifest_IsInternallyConsistent()
|
||||
{
|
||||
Assert.Empty(SessionIndexFieldManifest.AlwaysProjected
|
||||
.Intersect(SessionIndexFieldManifest.ConditionallyProjected, StringComparer.Ordinal));
|
||||
Assert.Equal(SessionIndexFieldManifest.All.Length,
|
||||
SessionIndexFieldManifest.All.Distinct(StringComparer.Ordinal).Count());
|
||||
Assert.Empty(SessionIndexFieldManifest.PrimaryOnly
|
||||
.Intersect(SessionIndexFieldManifest.All, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryManifestField_HasASampleValueInThisTest()
|
||||
{
|
||||
var missing = SessionIndexFieldManifest.All
|
||||
.Where(f => !SampleValues.ContainsKey(f))
|
||||
.ToList();
|
||||
Assert.True(missing.Count == 0,
|
||||
"New manifest field(s) without a sample value in SessionIndexFieldManifestTests.SampleValues:\n - "
|
||||
+ string.Join("\n - ", missing));
|
||||
}
|
||||
|
||||
// ── Builder ↔ manifest (bidirectional) ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FullyPopulatedRow_BuilderOutputMatchesManifestExactly()
|
||||
{
|
||||
var idx = TableStorageService.BuildSessionIndexEntity(FullSessionRow(), IndexRowKey, StartedAt);
|
||||
var dataKeys = DataKeys(idx);
|
||||
|
||||
var missingFromBuilder = SessionIndexFieldManifest.All.Except(dataKeys, StringComparer.Ordinal).ToList();
|
||||
var missingFromManifest = dataKeys.Except(SessionIndexFieldManifest.All, StringComparer.Ordinal).ToList();
|
||||
|
||||
Assert.True(missingFromBuilder.Count == 0,
|
||||
"Manifest fields BuildSessionIndexEntity did not project from a fully-populated row:\n - "
|
||||
+ string.Join("\n - ", missingFromBuilder));
|
||||
Assert.True(missingFromManifest.Count == 0,
|
||||
"BuildSessionIndexEntity projects fields missing from SessionIndexFieldManifest:\n - "
|
||||
+ string.Join("\n - ", missingFromManifest));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MinimalRow_BuilderOutputMatchesAlwaysProjectedExactly()
|
||||
{
|
||||
var minimal = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
["StartedAt"] = new DateTimeOffset(StartedAt),
|
||||
};
|
||||
|
||||
var idx = TableStorageService.BuildSessionIndexEntity(minimal, IndexRowKey, StartedAt);
|
||||
var dataKeys = DataKeys(idx);
|
||||
|
||||
var missing = SessionIndexFieldManifest.AlwaysProjected.Except(dataKeys, StringComparer.Ordinal).ToList();
|
||||
var unexpected = dataKeys.Except(SessionIndexFieldManifest.AlwaysProjected, StringComparer.Ordinal).ToList();
|
||||
|
||||
Assert.True(missing.Count == 0,
|
||||
"AlwaysProjected fields absent from a minimal-row rebuild (default missing in builder):\n - "
|
||||
+ string.Join("\n - ", missing));
|
||||
Assert.True(unexpected.Count == 0,
|
||||
"Fields written for a minimal row although classified ConditionallyProjected (or unlisted):\n - "
|
||||
+ string.Join("\n - ", unexpected));
|
||||
}
|
||||
|
||||
// ── Reader coverage ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void EveryManifestField_InfluencesMappedSessionSummary()
|
||||
{
|
||||
var sut = NewMapperService();
|
||||
var baseline = Serialize(sut.MapIndexEntityToSessionSummary(MinimalIndexEntity()));
|
||||
|
||||
var silent = new List<string>();
|
||||
foreach (var field in SessionIndexFieldManifest.All)
|
||||
{
|
||||
var entity = MinimalIndexEntity();
|
||||
entity[field] = SampleValues[field];
|
||||
var mapped = Serialize(sut.MapIndexEntityToSessionSummary(entity));
|
||||
if (mapped == baseline)
|
||||
silent.Add(field);
|
||||
}
|
||||
|
||||
Assert.True(silent.Count == 0,
|
||||
"Manifest fields whose value does NOT surface in MapToSessionSummary — either the reader "
|
||||
+ "ignores the column (dead projection) or the sample value needs adjusting:\n - "
|
||||
+ string.Join("\n - ", silent));
|
||||
}
|
||||
|
||||
// ── Merge guard ──────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void FindNonManifestKeys_FlagsOnlyForeignDataKeys()
|
||||
{
|
||||
var merge = new TableEntity(TenantId, IndexRowKey)
|
||||
{
|
||||
["Status"] = "Failed",
|
||||
["RebootCount"] = 2,
|
||||
["BrandNewField"] = "x",
|
||||
};
|
||||
|
||||
var offenders = SessionIndexFieldManifest.FindNonManifestKeys(merge);
|
||||
|
||||
Assert.Equal(new[] { "BrandNewField" }, offenders);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FindNonManifestKeys_EmptyForPureManifestMerge()
|
||||
{
|
||||
var merge = new TableEntity(TenantId, IndexRowKey)
|
||||
{
|
||||
["EventCount"] = 7,
|
||||
["LastEventAt"] = new DateTimeOffset(StartedAt),
|
||||
};
|
||||
|
||||
Assert.Empty(SessionIndexFieldManifest.FindNonManifestKeys(merge));
|
||||
}
|
||||
|
||||
// ── Full-rebuild upsert semantics ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertSessionIndex_UsesReplaceMode()
|
||||
{
|
||||
// Replace (not merge) is what heals stale conditional columns after the primary
|
||||
// cleared them — merge-mode upsert cannot remove a column.
|
||||
var harness = new UpsertHarness();
|
||||
|
||||
await harness.Sut.UpsertSessionIndexAsync(FullSessionRow(), StartedAt);
|
||||
|
||||
harness.Index.Verify(t => t.UpsertEntityAsync(
|
||||
It.Is<TableEntity>(e => e.RowKey.EndsWith("_" + SessionId, StringComparison.Ordinal)),
|
||||
TableUpdateMode.Replace,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
// IndexRowKey written back onto the Sessions row (merge).
|
||||
harness.Sessions.Verify(t => t.UpdateEntityAsync(
|
||||
It.Is<ITableEntity>(e => e.PartitionKey == TenantId && e.RowKey == SessionId),
|
||||
It.IsAny<ETag>(),
|
||||
TableUpdateMode.Merge,
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpsertSessionIndex_DeletesStaleRowKey_WhenStartedAtShifted()
|
||||
{
|
||||
var staleRowKey = "9999999999999999999_" + SessionId;
|
||||
var row = FullSessionRow();
|
||||
row["IndexRowKey"] = staleRowKey;
|
||||
|
||||
var harness = new UpsertHarness();
|
||||
|
||||
await harness.Sut.UpsertSessionIndexAsync(row, StartedAt);
|
||||
|
||||
harness.Index.Verify(t => t.DeleteEntityAsync(
|
||||
TenantId, staleRowKey, It.IsAny<ETag>(), It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A Sessions row carrying every known field. Deliberately built from SampleValues (the
|
||||
/// test-local list), NOT from the manifest — deriving the input from the manifest would
|
||||
/// make the bidirectional check circular: a field dropped from the manifest would also
|
||||
/// vanish from the input row and the drift would pass unnoticed.
|
||||
/// </summary>
|
||||
private static TableEntity FullSessionRow()
|
||||
{
|
||||
var row = new TableEntity(TenantId, SessionId);
|
||||
foreach (var (field, value) in SampleValues)
|
||||
{
|
||||
// SessionId comes from the RowKey; the builder never reads a SessionId property.
|
||||
if (field == "SessionId")
|
||||
continue;
|
||||
row[field] = value;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private static TableEntity MinimalIndexEntity() => new(TenantId, IndexRowKey);
|
||||
|
||||
private static List<string> DataKeys(TableEntity entity)
|
||||
=> entity.Keys.Except(SystemKeys, StringComparer.Ordinal).ToList();
|
||||
|
||||
private static string Serialize(object o) => JsonSerializer.Serialize(o);
|
||||
|
||||
private static TableStorageService NewMapperService()
|
||||
=> new(Mock.Of<TableServiceClient>(), NullLogger<TableStorageService>.Instance);
|
||||
|
||||
private sealed class UpsertHarness
|
||||
{
|
||||
public Mock<TableClient> Sessions { get; } = new();
|
||||
public Mock<TableClient> Index { get; } = new();
|
||||
public TableStorageService Sut { get; }
|
||||
|
||||
public UpsertHarness()
|
||||
{
|
||||
Index
|
||||
.Setup(t => t.UpsertEntityAsync(It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Mock.Of<Response>());
|
||||
Index
|
||||
.Setup(t => t.DeleteEntityAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<ETag>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Mock.Of<Response>());
|
||||
Sessions
|
||||
.Setup(t => t.UpdateEntityAsync(It.IsAny<ITableEntity>(), It.IsAny<ETag>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Mock.Of<Response>());
|
||||
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.Sessions)).Returns(Sessions.Object);
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.SessionsIndex)).Returns(Index.Object);
|
||||
Sut = new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using RequestContext = AutopilotMonitor.Functions.Helpers.RequestContext;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Fragilitätsaudit P5.3/P6.1: <c>RequestContextExtensions.ResolveSessionScopeAsync</c> is the
|
||||
/// single implementation of the former 15-site copy-paste session-scope fallback, and
|
||||
/// <c>TableStorageService.ResolveSessionTenantIdAsync</c> replaces the cross-partition
|
||||
/// SessionsIndex scan with a SessionTenantLookup point-read (legacy scan fallback + self-heal).
|
||||
/// These tests pin the semantics every migrated endpoint now inherits.
|
||||
/// </summary>
|
||||
public class SessionScopeResolutionTests
|
||||
{
|
||||
private const string HomeTenant = "11111111-1111-1111-1111-111111111111";
|
||||
private const string OtherTenant = "44444444-4444-4444-4444-444444444444";
|
||||
private const string SessionId = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
// ── RequestContextExtensions.ResolveSessionScopeAsync ───────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task NonGlobalCaller_KeepsTargetTenant_WithoutTouchingTheRepo()
|
||||
{
|
||||
var repo = new Mock<ISessionRepository>(MockBehavior.Strict);
|
||||
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, UserRole = Constants.TenantRoles.Admin };
|
||||
|
||||
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId);
|
||||
|
||||
Assert.Equal(HomeTenant, result);
|
||||
repo.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GlobalScope_CrossTenantSession_ResolvesToOwningTenant()
|
||||
{
|
||||
var repo = new Mock<ISessionRepository>();
|
||||
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(OtherTenant);
|
||||
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalReader = true };
|
||||
|
||||
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId);
|
||||
|
||||
Assert.Equal(OtherTenant, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GlobalScope_OwnTenantSession_KeepsTargetTenant()
|
||||
{
|
||||
// Case-insensitive comparison: a resolver returning a different casing must not
|
||||
// count as "different tenant".
|
||||
var repo = new Mock<ISessionRepository>();
|
||||
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(HomeTenant.ToUpperInvariant());
|
||||
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalAdmin = true };
|
||||
|
||||
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId);
|
||||
|
||||
Assert.Equal(HomeTenant, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GlobalScope_UnknownSession_KeepsTargetTenant_SoEndpointNotFoundHandlingStaysInCharge()
|
||||
{
|
||||
var repo = new Mock<ISessionRepository>();
|
||||
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync((string?)null);
|
||||
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalAdmin = true };
|
||||
|
||||
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId);
|
||||
|
||||
Assert.Equal(HomeTenant, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequireGlobalAdmin_ExcludesGlobalReader_FromCrossTenantWrites()
|
||||
{
|
||||
// Write paths (annotation upsert) gate the resolve on IsGlobalAdmin: a read-only
|
||||
// Global Reader keeps the middleware-validated tenant and can never steer a write
|
||||
// into a foreign tenant.
|
||||
var repo = new Mock<ISessionRepository>(MockBehavior.Strict);
|
||||
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalReader = true };
|
||||
|
||||
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId, requireGlobalAdmin: true);
|
||||
|
||||
Assert.Equal(HomeTenant, result);
|
||||
repo.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequireGlobalAdmin_StillResolves_ForGlobalAdmin()
|
||||
{
|
||||
var repo = new Mock<ISessionRepository>();
|
||||
repo.Setup(r => r.ResolveSessionTenantIdAsync(SessionId)).ReturnsAsync(OtherTenant);
|
||||
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, IsGlobalAdmin = true };
|
||||
|
||||
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId, requireGlobalAdmin: true);
|
||||
|
||||
Assert.Equal(OtherTenant, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DelegatedCaller_GetsNoFallback_ByDesign()
|
||||
{
|
||||
// Deliberate: delegated (MSP) callers are NOT global scope — extending the fallback
|
||||
// to AllowedTenantIds would widen the authz surface and is a separate user decision.
|
||||
var repo = new Mock<ISessionRepository>(MockBehavior.Strict);
|
||||
var ctx = new RequestContext
|
||||
{
|
||||
TenantId = HomeTenant,
|
||||
TargetTenantId = OtherTenant,
|
||||
IsDelegatedReader = true,
|
||||
AllowedTenantIds = new[] { OtherTenant },
|
||||
};
|
||||
|
||||
var result = await ctx.ResolveSessionScopeAsync(repo.Object, SessionId);
|
||||
|
||||
Assert.Equal(OtherTenant, result);
|
||||
repo.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
// ── TableStorageService.ResolveSessionTenantIdAsync ─────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task PointReadHit_ReturnsTenant_WithoutScanningTheIndex()
|
||||
{
|
||||
var harness = new ResolveHarness(lookupRow: LookupRow(OtherTenant));
|
||||
|
||||
var result = await harness.Sut.ResolveSessionTenantIdAsync(SessionId);
|
||||
|
||||
Assert.Equal(OtherTenant, result);
|
||||
harness.Index.Verify(t => t.QueryAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PointReadMiss_FallsBackToScan_AndHealsTheLookupRow()
|
||||
{
|
||||
var indexRow = new TableEntity(OtherTenant, "0000000000000000001_" + SessionId) { ["SessionId"] = SessionId };
|
||||
var harness = new ResolveHarness(lookupRow: null, indexRows: new[] { indexRow });
|
||||
|
||||
var result = await harness.Sut.ResolveSessionTenantIdAsync(SessionId);
|
||||
|
||||
Assert.Equal(OtherTenant, result);
|
||||
// Self-heal: the lookup row is written so the next resolve is a point-read.
|
||||
harness.Lookup.Verify(t => t.UpsertEntityAsync(
|
||||
It.Is<TableEntity>(e => e.PartitionKey == SessionId
|
||||
&& e.RowKey == "tenant"
|
||||
&& e.GetString("TenantId") == OtherTenant),
|
||||
It.IsAny<TableUpdateMode>(),
|
||||
It.IsAny<CancellationToken>()),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownSession_ReturnsNull_AndWritesNoHealRow()
|
||||
{
|
||||
var harness = new ResolveHarness(lookupRow: null, indexRows: Array.Empty<TableEntity>());
|
||||
|
||||
var result = await harness.Sut.ResolveSessionTenantIdAsync(SessionId);
|
||||
|
||||
Assert.Null(result);
|
||||
harness.Lookup.Verify(t => t.UpsertEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidSessionId_Throws_BeforeAnyStorageCall()
|
||||
{
|
||||
var harness = new ResolveHarness(lookupRow: null);
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(
|
||||
() => harness.Sut.ResolveSessionTenantIdAsync("not-a-guid"));
|
||||
}
|
||||
|
||||
// ── RecomputeTriggerGate ─────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("Admin", true)]
|
||||
[InlineData("Operator", true)]
|
||||
[InlineData("Viewer", false)]
|
||||
[InlineData("", false)]
|
||||
public void OwnTenantRoles_GateRecompute(string role, bool expected)
|
||||
{
|
||||
var ctx = new RequestContext { TenantId = HomeTenant, TargetTenantId = HomeTenant, UserRole = role };
|
||||
Assert.Equal(expected, RecomputeTriggerGate.CanTriggerRecompute(ctx, HomeTenant));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlobalAdmin_MayRecompute_CrossTenant()
|
||||
{
|
||||
var ctx = new RequestContext { TenantId = HomeTenant, IsGlobalAdmin = true };
|
||||
Assert.True(RecomputeTriggerGate.CanTriggerRecompute(ctx, OtherTenant));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GlobalReader_MayNotRecompute_Anywhere()
|
||||
{
|
||||
var ctx = new RequestContext { TenantId = HomeTenant, IsGlobalReader = true };
|
||||
Assert.False(RecomputeTriggerGate.CanTriggerRecompute(ctx, HomeTenant));
|
||||
Assert.False(RecomputeTriggerGate.CanTriggerRecompute(ctx, OtherTenant));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AdminRole_DoesNotCarryCrossTenant()
|
||||
{
|
||||
// A tenant Admin viewing a FOREIGN tenant's session (delegated read) must not be able
|
||||
// to trigger recomputes there — the role binds to the caller's own tenant.
|
||||
var ctx = new RequestContext { TenantId = HomeTenant, UserRole = Constants.TenantRoles.Admin };
|
||||
Assert.False(RecomputeTriggerGate.CanTriggerRecompute(ctx, OtherTenant));
|
||||
}
|
||||
|
||||
// ── Harness ──────────────────────────────────────────────────────────────────
|
||||
|
||||
private static TableEntity LookupRow(string tenantId)
|
||||
=> new(SessionId, "tenant") { ["TenantId"] = tenantId };
|
||||
|
||||
private sealed class ResolveHarness
|
||||
{
|
||||
public Mock<TableClient> Lookup { get; } = new();
|
||||
public Mock<TableClient> Index { get; } = new();
|
||||
public TableStorageService Sut { get; }
|
||||
|
||||
public ResolveHarness(TableEntity? lookupRow, TableEntity[]? indexRows = null)
|
||||
{
|
||||
if (lookupRow != null)
|
||||
{
|
||||
Lookup
|
||||
.Setup(t => t.GetEntityAsync<TableEntity>(SessionId, "tenant", It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(lookupRow, Mock.Of<Response>()));
|
||||
}
|
||||
else
|
||||
{
|
||||
Lookup
|
||||
.Setup(t => t.GetEntityAsync<TableEntity>(SessionId, "tenant", It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(404, "Not Found"));
|
||||
}
|
||||
Lookup
|
||||
.Setup(t => t.UpsertEntityAsync(It.IsAny<TableEntity>(), It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Mock.Of<Response>());
|
||||
|
||||
var page = Page<TableEntity>.FromValues(indexRows ?? Array.Empty<TableEntity>(), null, Mock.Of<Response>());
|
||||
Index
|
||||
.Setup(t => t.QueryAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<int?>(), It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(AsyncPageable<TableEntity>.FromPages(new[] { page }));
|
||||
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.SessionTenantLookup)).Returns(Lookup.Object);
|
||||
serviceClient.Setup(s => s.GetTableClient(Constants.TableNames.SessionsIndex)).Returns(Index.Object);
|
||||
Sut = new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -33,8 +33,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
public Task<SessionSummary?> GetSessionAsync(string tenantId, string sessionId)
|
||||
=> _storage.GetSessionAsync(tenantId, sessionId);
|
||||
|
||||
public Task<string?> FindSessionTenantIdAsync(string sessionId)
|
||||
=> _storage.FindSessionTenantIdAsync(sessionId);
|
||||
public Task<string?> ResolveSessionTenantIdAsync(string sessionId)
|
||||
=> _storage.ResolveSessionTenantIdAsync(sessionId);
|
||||
|
||||
public Task<List<SessionSummary>> GetOpenSessionsForDeviceAsync(string tenantId, string serialNumber)
|
||||
=> _storage.GetOpenSessionsForDeviceAsync(tenantId, serialNumber);
|
||||
|
||||
+4
-14
@@ -38,21 +38,11 @@ namespace AutopilotMonitor.Functions.Functions.Annotations
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware.
|
||||
// Global-scope (GA or read-only GlobalReader) callers resolve the session's actual
|
||||
// tenant upfront so the read works cross-tenant.
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var effectiveTenantId = requestCtx.TargetTenantId;
|
||||
|
||||
// Global-scope (GA or read-only GlobalReader) cross-tenant fallback: resolve
|
||||
// the actual tenant upfront so the read works cross-tenant.
|
||||
if (requestCtx.HasGlobalScope)
|
||||
{
|
||||
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
|
||||
if (resolvedTenantId != null
|
||||
&& !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
effectiveTenantId = resolvedTenantId;
|
||||
}
|
||||
}
|
||||
var effectiveTenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
|
||||
|
||||
var annotations = await _annotationRepo.GetForSessionAsync(effectiveTenantId, sessionId);
|
||||
var visible = FilterLanesForCaller(annotations, requestCtx.HasGlobalScope);
|
||||
|
||||
+8
-14
@@ -65,20 +65,14 @@ namespace AutopilotMonitor.Functions.Functions.Annotations
|
||||
// Tenant identity: non-GA callers are pinned to their middleware-validated
|
||||
// tenant (never a body value — prevents horizontal escalation). Global
|
||||
// Admins may annotate foreign-tenant sessions (the GA labeling flow), so
|
||||
// resolve the session's actual tenant like the other session reads do.
|
||||
// Resolution happens BEFORE the lane gate: tenant-role lanes must bind to
|
||||
// the caller's OWN tenant, so a GA who is also an own-tenant admin cannot
|
||||
// write another tenant's operator/tenantadmin lanes.
|
||||
var effectiveTenantId = requestCtx.TargetTenantId;
|
||||
if (requestCtx.IsGlobalAdmin)
|
||||
{
|
||||
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
|
||||
if (resolvedTenantId != null
|
||||
&& !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
effectiveTenantId = resolvedTenantId;
|
||||
}
|
||||
}
|
||||
// resolve the session's actual tenant like the other session reads do —
|
||||
// requireGlobalAdmin because this is a WRITE (a read-only GlobalReader
|
||||
// must never steer it cross-tenant). Resolution happens BEFORE the lane
|
||||
// gate: tenant-role lanes must bind to the caller's OWN tenant, so a GA
|
||||
// who is also an own-tenant admin cannot write another tenant's
|
||||
// operator/tenantadmin lanes.
|
||||
var effectiveTenantId = await requestCtx.ResolveSessionScopeAsync(
|
||||
_sessionRepo, sessionId, requireGlobalAdmin: true);
|
||||
|
||||
var isOwnTenant = string.Equals(effectiveTenantId, requestCtx.TenantId, StringComparison.OrdinalIgnoreCase);
|
||||
if (!IsLaneWritableByCaller(
|
||||
|
||||
+8
-15
@@ -61,26 +61,19 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
|
||||
// Junk/placeholder serials have no device identity — by design they never have a
|
||||
// chain (disclosed in the fleet aggregate instead), so null here is honest.
|
||||
//
|
||||
// The primary key here is the SERIAL, not the session — a global-scope caller
|
||||
// without an explicit tenantId query param can only be steered to the owning
|
||||
// tenant when a sessionId travels along (point-read; mirrors
|
||||
// GetSessionTimeAttribution).
|
||||
var serialKey = DeviceJourneyCalculator.NormalizeSerial(rawSerial);
|
||||
var effectiveTenantId = requestCtx.TargetTenantId;
|
||||
var effectiveTenantId = string.IsNullOrEmpty(sessionId)
|
||||
? requestCtx.TargetTenantId
|
||||
: await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId!);
|
||||
var history = serialKey == null
|
||||
? null
|
||||
: await _metricsRepo.GetDeviceHistoryAsync(effectiveTenantId, serialKey);
|
||||
|
||||
// Global-scope fallback (mirrors GetSessionTimeAttribution): a GA/operator opening
|
||||
// a session without an explicit tenantId query param reads their home partition —
|
||||
// resolve the owning tenant via the session and retry once.
|
||||
if (history == null && serialKey != null && requestCtx.HasGlobalScope && !string.IsNullOrEmpty(sessionId))
|
||||
{
|
||||
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId!);
|
||||
if (resolvedTenantId != null &&
|
||||
!string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
effectiveTenantId = resolvedTenantId;
|
||||
history = await _metricsRepo.GetDeviceHistoryAsync(effectiveTenantId, serialKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt number for the requesting session — fail-soft: a missing session or an
|
||||
// empty basis yields null, never a guessed position.
|
||||
int? attemptNumber = null;
|
||||
|
||||
+5
-15
@@ -42,23 +42,13 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
try
|
||||
{
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware;
|
||||
// cross-tenant access via TargetTenantId (TenantScoping.QueryParam).
|
||||
// cross-tenant access via TargetTenantId (TenantScoping.QueryParam). Global-scope
|
||||
// callers resolve the session's owning tenant upfront (point-read; mirrors
|
||||
// GetSessionEventsFunction).
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var effectiveTenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
|
||||
|
||||
var breakdown = await _metricsRepo.GetSessionTimeBreakdownAsync(requestCtx.TargetTenantId, sessionId);
|
||||
|
||||
// Global-scope fallback (mirrors GetSessionEventsFunction): a GA/operator opening
|
||||
// a session without an explicit tenantId query param reads their home partition —
|
||||
// resolve the owning tenant and retry once.
|
||||
if (breakdown == null && requestCtx.HasGlobalScope)
|
||||
{
|
||||
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
|
||||
if (resolvedTenantId != null &&
|
||||
!string.Equals(resolvedTenantId, requestCtx.TargetTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
breakdown = await _metricsRepo.GetSessionTimeBreakdownAsync(resolvedTenantId, sessionId);
|
||||
}
|
||||
}
|
||||
var breakdown = await _metricsRepo.GetSessionTimeBreakdownAsync(effectiveTenantId, sessionId);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { success = true, breakdown });
|
||||
|
||||
@@ -103,7 +103,7 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
// throws when unauthenticated), so it never reaches this branch with an empty value.
|
||||
if (string.IsNullOrEmpty(tenantId) && scope == "raw-events:global")
|
||||
{
|
||||
tenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
|
||||
tenantId = await _sessionRepo.ResolveSessionTenantIdAsync(sessionId);
|
||||
}
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
|
||||
@@ -89,14 +89,7 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
|
||||
// Global-scope cross-tenant fallback: resolve the session's actual tenant so a GA /
|
||||
// Global Reader can dry-run against any tenant's session (mirrors GetRuleResultsFunction).
|
||||
if (requestCtx.HasGlobalScope)
|
||||
{
|
||||
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
|
||||
if (resolvedTenantId != null && !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
effectiveTenantId = resolvedTenantId;
|
||||
}
|
||||
}
|
||||
effectiveTenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
|
||||
|
||||
var session = await _sessionRepo.GetSessionAsync(effectiveTenantId, sessionId);
|
||||
if (session == null)
|
||||
|
||||
@@ -40,23 +40,29 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/{sessionId}/analysis")] HttpRequestData req,
|
||||
string sessionId)
|
||||
{
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var effectiveTenantId = requestCtx.TargetTenantId;
|
||||
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware.
|
||||
// Global-scope (GA or read-only GlobalReader) cross-tenant fallback: resolve actual tenant
|
||||
// upfront so the read works cross-tenant for any platform-scope caller.
|
||||
if (requestCtx.HasGlobalScope)
|
||||
{
|
||||
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
|
||||
if (resolvedTenantId != null && !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
effectiveTenantId = resolvedTenantId;
|
||||
}
|
||||
}
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var effectiveTenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
|
||||
|
||||
var reanalyze = string.Equals(req.Query["reanalyze"], "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Re-analysis deletes + rewrites rule results — an ACTION, not a view. The route is
|
||||
// MemberRead (Viewer included) so the read stays open, but the recompute trigger is
|
||||
// gated to write-capable callers (mirrors the UI hiding "Analyze now" for Viewer and
|
||||
// keeps a read-only GlobalReader from steering cross-tenant writes).
|
||||
if (reanalyze && !RecomputeTriggerGate.CanTriggerRecompute(requestCtx, effectiveTenantId))
|
||||
{
|
||||
var forbidden = req.CreateResponse(System.Net.HttpStatusCode.Forbidden);
|
||||
await forbidden.WriteAsJsonAsync(new
|
||||
{
|
||||
success = false,
|
||||
message = "Your role does not permit triggering re-analysis.",
|
||||
});
|
||||
return forbidden;
|
||||
}
|
||||
|
||||
// Rules whose StoreRuleResultAsync returned false during the on-demand reanalyze.
|
||||
// Reported back to the caller so the UI can render a warning banner without losing
|
||||
// the rules that DID persist. Mirrors the queue-path's throw-on-false retry trigger,
|
||||
|
||||
+5
-15
@@ -51,24 +51,14 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
|
||||
try
|
||||
{
|
||||
// Global-scope (GA or read-only GlobalReader) callers resolve the session's owning
|
||||
// tenant upfront (point-read; same pattern as the other session-detail reads). The
|
||||
// resolved tenant also stamps the projection below.
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var effectiveTenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
|
||||
|
||||
var transitions = await _transitionRepo.QueryBySessionAsync(
|
||||
requestCtx.TargetTenantId, sessionId, MaxTransitionsToLoad);
|
||||
|
||||
// Global-scope (GA or read-only GlobalReader) cross-tenant fallback (same pattern as other session-detail reads).
|
||||
var effectiveTenantId = requestCtx.TargetTenantId;
|
||||
if (transitions.Count == 0 && requestCtx.HasGlobalScope)
|
||||
{
|
||||
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
|
||||
if (resolvedTenantId != null &&
|
||||
!string.Equals(resolvedTenantId, requestCtx.TargetTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
transitions = await _transitionRepo.QueryBySessionAsync(
|
||||
resolvedTenantId, sessionId, MaxTransitionsToLoad);
|
||||
effectiveTenantId = resolvedTenantId;
|
||||
}
|
||||
}
|
||||
effectiveTenantId, sessionId, MaxTransitionsToLoad);
|
||||
|
||||
var projection = DecisionGraphBuilder.Build(effectiveTenantId, sessionId, transitions);
|
||||
|
||||
|
||||
+4
-3
@@ -83,8 +83,9 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
// preview a session in a tenant other than their JWT home tenant.
|
||||
// 2. JWT TargetTenantId, where the policy middleware mirrors the JWT tid for
|
||||
// non-RouteParam routes. Sufficient for tenant admins acting on their own tenant.
|
||||
// 3. SessionsIndex lookup keyed by sessionId — the historical fallback for the
|
||||
// "global" sentinel; preserved so existing callers without ?tenantId still work.
|
||||
// 3. Session-tenant lookup keyed by sessionId (point-read with legacy-scan
|
||||
// fallback) — the historical fallback for the "global" sentinel; preserved so
|
||||
// existing callers without ?tenantId still work.
|
||||
var explicitTenantId = query["tenantId"];
|
||||
string tenantId;
|
||||
if (requestCtx.HasGlobalScope
|
||||
@@ -98,7 +99,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
tenantId = requestCtx.TargetTenantId;
|
||||
if (string.IsNullOrEmpty(tenantId) || string.Equals(tenantId, "global", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
tenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId) ?? string.Empty;
|
||||
tenantId = await _sessionRepo.ResolveSessionTenantIdAsync(sessionId) ?? string.Empty;
|
||||
}
|
||||
}
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
|
||||
+9
-17
@@ -98,17 +98,13 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
|
||||
if (pagination.PageSize == null)
|
||||
{
|
||||
// Legacy unpaginated path — full list, no nextLink.
|
||||
var events = await _sessionRepo.GetSessionEventsAsync(requestCtx.TargetTenantId, sessionId);
|
||||
|
||||
if (events.Count == 0 && requestCtx.HasGlobalScope)
|
||||
{
|
||||
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
|
||||
if (resolvedTenantId != null && !string.Equals(resolvedTenantId, requestCtx.TargetTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
events = await _sessionRepo.GetSessionEventsAsync(resolvedTenantId, sessionId);
|
||||
}
|
||||
}
|
||||
// Legacy unpaginated path — full list, no nextLink. Global-scope callers
|
||||
// resolve the session's owning tenant UPFRONT (point-read) — the former
|
||||
// "events.Count == 0 → cross-partition scan" retry conflated "session lives
|
||||
// elsewhere" with "session genuinely has no events" and paid a full
|
||||
// SessionsIndex scan for every empty result.
|
||||
var unpaginatedTenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
|
||||
var events = await _sessionRepo.GetSessionEventsAsync(unpaginatedTenantId, sessionId);
|
||||
|
||||
var filtered = hasFilters
|
||||
? ApplyFilters(events, filterEventType, filterSeverity, filterSource).ToList()
|
||||
@@ -154,13 +150,9 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
// *on every page*, not just page 1, so external callers
|
||||
// that strip nextLink down to the bare continuation token
|
||||
// (older MCP clients, deployed agents) still validate.
|
||||
// The lookup is one indexed point-read on SessionsIndex —
|
||||
// The lookup is one point-read on SessionTenantLookup —
|
||||
// negligible compared to the events page fetch itself.
|
||||
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
|
||||
if (resolvedTenantId != null && !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
effectiveTenantId = resolvedTenantId;
|
||||
}
|
||||
effectiveTenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,22 +40,13 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
|
||||
try
|
||||
{
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
||||
// Cross-tenant access check handled by middleware (TargetTenantId)
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware.
|
||||
// Cross-tenant access check handled by middleware (TargetTenantId); global-scope
|
||||
// callers resolve the session's owning tenant upfront (point-read).
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var effectiveTenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
|
||||
|
||||
var session = await _sessionRepo.GetSessionAsync(requestCtx.TargetTenantId, sessionId);
|
||||
|
||||
// Global-scope (GA or read-only GlobalReader) cross-tenant fallback: if not found in the
|
||||
// effective tenant, try resolving the actual tenant via SessionsIndex
|
||||
if (session == null && requestCtx.HasGlobalScope)
|
||||
{
|
||||
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
|
||||
if (resolvedTenantId != null && !string.Equals(resolvedTenantId, requestCtx.TargetTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
session = await _sessionRepo.GetSessionAsync(resolvedTenantId, sessionId);
|
||||
}
|
||||
}
|
||||
var session = await _sessionRepo.GetSessionAsync(effectiveTenantId, sessionId);
|
||||
|
||||
if (session == null)
|
||||
{
|
||||
|
||||
+4
-20
@@ -68,33 +68,17 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
|
||||
try
|
||||
{
|
||||
// Cross-tenant scope resolved upfront (point-read; same pattern as the other
|
||||
// session detail reads). The GlobalReadOrAdmin route gate restricts callers to
|
||||
// platform scope, which is exactly what the helper's HasGlobalScope gate checks.
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var tenantIdForLoad = requestCtx.TargetTenantId;
|
||||
var tenantIdForLoad = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
|
||||
|
||||
var signals = await _signalRepo.QueryBySessionAsync(
|
||||
tenantIdForLoad, sessionId, MaxSignalsToLoad);
|
||||
var transitions = await _transitionRepo.QueryBySessionAsync(
|
||||
tenantIdForLoad, sessionId, MaxTransitionsToLoad);
|
||||
|
||||
// Global-scope cross-tenant fallback — same pattern as the other session detail read
|
||||
// endpoints. Resolve via SessionsIndex when the effective tenant has no data. (The
|
||||
// GlobalReadOrAdmin gate already restricts this route to platform-scope callers — GA or
|
||||
// read-only GlobalReader — so we don't re-check the role here; the SessionsIndex lookup
|
||||
// is the cleanest way to find the actual tenant for a loose sessionId.)
|
||||
if (signals.Count == 0 && transitions.Count == 0)
|
||||
{
|
||||
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
|
||||
if (resolvedTenantId != null &&
|
||||
!string.Equals(resolvedTenantId, tenantIdForLoad, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
signals = await _signalRepo.QueryBySessionAsync(
|
||||
resolvedTenantId, sessionId, MaxSignalsToLoad);
|
||||
transitions = await _transitionRepo.QueryBySessionAsync(
|
||||
resolvedTenantId, sessionId, MaxTransitionsToLoad);
|
||||
tenantIdForLoad = resolvedTenantId;
|
||||
}
|
||||
}
|
||||
|
||||
// Read the live reducer's version via a transient DecisionEngine instance.
|
||||
// The engine has no heavy construction cost (no DI, no state) so this is
|
||||
// cheaper than threading a singleton through.
|
||||
|
||||
+5
-12
@@ -57,21 +57,14 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
|
||||
try
|
||||
{
|
||||
// Global-scope (GA or read-only GlobalReader) callers resolve the session's owning
|
||||
// tenant upfront (point-read) — mirrors GetSessionEventsFunction; the former
|
||||
// "Count == 0 → scan" retry treated empty results as wrong-tenant evidence.
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var effectiveTenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
|
||||
|
||||
var signals = await _signalRepo.QueryBySessionAsync(
|
||||
requestCtx.TargetTenantId, sessionId, maxResults);
|
||||
|
||||
// Global-scope (GA or read-only GlobalReader) cross-tenant fallback — mirror GetSessionEventsFunction pattern.
|
||||
if (signals.Count == 0 && requestCtx.HasGlobalScope)
|
||||
{
|
||||
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
|
||||
if (resolvedTenantId != null &&
|
||||
!string.Equals(resolvedTenantId, requestCtx.TargetTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
signals = await _signalRepo.QueryBySessionAsync(resolvedTenantId, sessionId, maxResults);
|
||||
}
|
||||
}
|
||||
effectiveTenantId, sessionId, maxResults);
|
||||
|
||||
return await req.OkAsync(new
|
||||
{
|
||||
|
||||
@@ -78,7 +78,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
// 1. Explicit body.TenantId on GA calls (cross-tenant restore — the common case
|
||||
// when restoring from the Session Cleanup admin page).
|
||||
// 2. JWT TargetTenantId (single-tenant operators).
|
||||
// 3. SessionsIndex lookup keyed by sessionId (rare but preserved).
|
||||
// 3. Session-tenant lookup keyed by sessionId (rare but preserved).
|
||||
// 4. body.TenantId again — last-resort fallback for the "Sessions row gone" path
|
||||
// where neither the index nor the JWT tenant can resolve the target.
|
||||
var requestCtx = req.GetRequestContext();
|
||||
@@ -95,7 +95,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
tenantId = requestCtx.TargetTenantId;
|
||||
if (string.IsNullOrEmpty(tenantId) || string.Equals(tenantId, "global", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
tenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId) ?? string.Empty;
|
||||
tenantId = await _sessionRepo.ResolveSessionTenantIdAsync(sessionId) ?? string.Empty;
|
||||
}
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
|
||||
@@ -63,6 +63,15 @@ public class SearchSessionsFunction
|
||||
|
||||
var filter = BuildFilter(query);
|
||||
|
||||
// Free-text minimum: a 1-char substring matches nearly every row and turns the
|
||||
// backfill loop into a full scan for nothing (mirrors search/quick's minimum).
|
||||
if (filter.Q != null && filter.Q.Length < 2)
|
||||
{
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new { success = false, message = "q must be at least 2 characters" });
|
||||
return bad;
|
||||
}
|
||||
|
||||
var pagination = SearchSessionsPagination.ParsePagination(query);
|
||||
if (pagination.Error != null)
|
||||
{
|
||||
@@ -141,6 +150,8 @@ public class SearchSessionsFunction
|
||||
AgentVersionPrefix = query["agentVersionPrefix"],
|
||||
ImeAgentVersionPrefix = query["imeAgentVersionPrefix"],
|
||||
ConnectionType = query["connectionType"],
|
||||
// Free-text search (dashboard search box); trimmed, empty → null.
|
||||
Q = string.IsNullOrWhiteSpace(query["q"]) ? null : query["q"]!.Trim(),
|
||||
// Limit field is a no-op in the paged path — pagination drives count.
|
||||
};
|
||||
|
||||
|
||||
+14
-9
@@ -49,22 +49,27 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability
|
||||
// Cross-tenant access check handled by middleware (TargetTenantId)
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var userIdentifier = requestCtx.UserPrincipalName;
|
||||
var effectiveTenantId = requestCtx.TargetTenantId;
|
||||
|
||||
// Global-scope (GA or read-only GlobalReader) cross-tenant fallback: resolve actual tenant
|
||||
// upfront so the read works cross-tenant for any platform-scope caller.
|
||||
if (requestCtx.HasGlobalScope)
|
||||
{
|
||||
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
|
||||
if (resolvedTenantId != null && !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
effectiveTenantId = resolvedTenantId;
|
||||
}
|
||||
}
|
||||
var effectiveTenantId = await requestCtx.ResolveSessionScopeAsync(_sessionRepo, sessionId);
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query);
|
||||
var rescan = string.Equals(query["rescan"], "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// A re-scan regenerates + stores the report — an ACTION on a MemberRead route;
|
||||
// gate it to write-capable callers (see RecomputeTriggerGate).
|
||||
if (rescan && !RecomputeTriggerGate.CanTriggerRecompute(requestCtx, effectiveTenantId))
|
||||
{
|
||||
var forbidden = req.CreateResponse(System.Net.HttpStatusCode.Forbidden);
|
||||
await forbidden.WriteAsJsonAsync(new
|
||||
{
|
||||
success = false,
|
||||
message = "Your role does not permit triggering a vulnerability re-scan.",
|
||||
});
|
||||
return forbidden;
|
||||
}
|
||||
|
||||
if (rescan)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Gate for on-demand recompute triggers that ride on MemberRead GET routes
|
||||
/// (<c>?reanalyze=true</c> on sessions/{id}/analysis, <c>?rescan=true</c> on
|
||||
/// sessions/{id}/vulnerability-report). Recomputes delete + rewrite stored results —
|
||||
/// they are ACTIONS, not views: Viewer and the read-only Global Reader see the results
|
||||
/// but must not trigger regeneration (decided 2026-08-13 with the Viewer tier; the UI
|
||||
/// hides the buttons, this is the API-side enforcement).
|
||||
/// </summary>
|
||||
public static class RecomputeTriggerGate
|
||||
{
|
||||
/// <summary>
|
||||
/// True when the caller may trigger a recompute for <paramref name="effectiveTenantId"/>:
|
||||
/// a Global Admin anywhere, or an OWN-TENANT Admin/Operator. Viewer, Global Reader, and
|
||||
/// delegated (read-tier) callers are excluded, as is any tenant role applied cross-tenant.
|
||||
/// </summary>
|
||||
public static bool CanTriggerRecompute(RequestContext ctx, string effectiveTenantId)
|
||||
{
|
||||
if (ctx.IsGlobalAdmin)
|
||||
return true;
|
||||
|
||||
var isOwnTenant = string.Equals(effectiveTenantId, ctx.TenantId, StringComparison.OrdinalIgnoreCase);
|
||||
return isOwnTenant
|
||||
&& (ctx.UserRole == Constants.TenantRoles.Admin || ctx.UserRole == Constants.TenantRoles.Operator);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
|
||||
@@ -146,6 +147,37 @@ public static class RequestContextExtensions
|
||||
|| ctx.UserRole == Constants.TenantRoles.Operator
|
||||
|| ctx.UserRole == Constants.TenantRoles.Viewer;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective tenantId for a session-scoped request (Fragilitätsaudit P5.3 —
|
||||
/// the one implementation of the former 15-site copy-paste fallback).
|
||||
/// <para>
|
||||
/// Non-global callers always keep <see cref="RequestContext.TargetTenantId"/> (the middleware
|
||||
/// already validated it; no cross-tenant reach). Global-scope callers resolve the session's
|
||||
/// owning tenant via <see cref="ISessionRepository.ResolveSessionTenantIdAsync"/> (point-read
|
||||
/// with legacy-scan fallback) and get the resolved tenant when it differs; an unknown session
|
||||
/// keeps <see cref="RequestContext.TargetTenantId"/> so each endpoint's own not-found handling
|
||||
/// (404 / empty 200 / 400) stays in charge.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="requireGlobalAdmin">
|
||||
/// True on WRITE paths: gate the cross-tenant resolve on <see cref="RequestContext.IsGlobalAdmin"/>
|
||||
/// instead of <see cref="RequestContext.HasGlobalScope"/>, so a read-only Global Reader can never
|
||||
/// steer a write into a foreign tenant.
|
||||
/// </param>
|
||||
public static async Task<string> ResolveSessionScopeAsync(
|
||||
this RequestContext ctx, ISessionRepository sessionRepo, string sessionId, bool requireGlobalAdmin = false)
|
||||
{
|
||||
var canReachAcrossTenants = requireGlobalAdmin ? ctx.IsGlobalAdmin : ctx.HasGlobalScope;
|
||||
if (!canReachAcrossTenants)
|
||||
return ctx.TargetTenantId;
|
||||
|
||||
var resolvedTenantId = await sessionRepo.ResolveSessionTenantIdAsync(sessionId);
|
||||
if (resolvedTenantId != null && !string.Equals(resolvedTenantId, ctx.TargetTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
return resolvedTenantId;
|
||||
|
||||
return ctx.TargetTenantId;
|
||||
}
|
||||
|
||||
/// <summary>Gets the correlation ID for this request (set by CorrelationIdMiddleware).</summary>
|
||||
public static string GetCorrelationId(this FunctionContext context)
|
||||
{
|
||||
|
||||
@@ -55,6 +55,7 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
new KeyValuePair<string, string?>("rebootCountMin", filter.RebootCountMin?.ToString(CultureInfo.InvariantCulture)),
|
||||
new KeyValuePair<string, string?>("rebootCountMax", filter.RebootCountMax?.ToString(CultureInfo.InvariantCulture)),
|
||||
new KeyValuePair<string, string?>("connectionType", filter.ConnectionType),
|
||||
new KeyValuePair<string, string?>("q", filter.Q),
|
||||
};
|
||||
if (filter.DeviceProperties != null)
|
||||
{
|
||||
|
||||
@@ -135,8 +135,13 @@ namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
AddContributionsRowStep(manifest, order: 21, contributionsRow);
|
||||
}
|
||||
|
||||
// ---- Step 22: Tombstone (SessionsIndex first, then Sessions). ----
|
||||
AddTombstoneStep(manifest, order: 22, sessionsIndexRow, sessionRow);
|
||||
// ---- Step 22: sessionId → tenantId lookup row (PK=sessionId, RK="tenant"). ----
|
||||
// Deleted just before the tombstone so cross-tenant resolution keeps working for
|
||||
// as long as the session itself exists; absent for legacy sessions (0-row step).
|
||||
await AddPkRkExactStepAsync(manifest, order: 22, table: Constants.TableNames.SessionTenantLookup, partitionKey: sessionId, rowKey: "tenant", cancellationToken);
|
||||
|
||||
// ---- Step 23: Tombstone (SessionsIndex first, then Sessions). ----
|
||||
AddTombstoneStep(manifest, order: 23, sessionsIndexRow, sessionRow);
|
||||
|
||||
// PreflightCounts derive from each step's RowCount, plus the AGGREGATE decrements length.
|
||||
manifest.PreflightCounts = ComputePreflightCounts(manifest);
|
||||
|
||||
@@ -66,5 +66,16 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
|
||||
SecurityValidator.EnsureValidGuid(normalizedTenantId, nameof(normalizedTenantId));
|
||||
return $"TenantId eq '{ODataSanitizer.EscapeValue(normalizedTenantId)}'";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>RowKey eq '{tenantId}'</c> — Variant D SafeWipe (RowKey-anchored full-table
|
||||
/// filter). For tables keyed <c>PK=<non-tenant dimension>, RK=tenantId</c>
|
||||
/// (UsageMetrics: PK=date). Full-table scan; reserve for retention-bounded tables.
|
||||
/// </summary>
|
||||
public static string RowKeyEquals(string normalizedTenantId)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(normalizedTenantId, nameof(normalizedTenantId));
|
||||
return $"RowKey eq '{ODataSanitizer.EscapeValue(normalizedTenantId)}'";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,26 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Variant D (RowKey-anchored full-table filter). For tables where the tenant id is
|
||||
/// the ROW key and the partition key is a non-tenant dimension (UsageMetrics:
|
||||
/// PK=date, RK=tenantId|"global"). Full-table scan — reserve for retention-bounded
|
||||
/// tables.
|
||||
/// </summary>
|
||||
public virtual Task<int> WipeByRowKeyAsync(
|
||||
string tableName, string normalizedTenantId, CancellationToken ct = default)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(normalizedTenantId, nameof(normalizedTenantId));
|
||||
var filter = OffboardingFilters.RowKeyEquals(normalizedTenantId);
|
||||
|
||||
return RunFetchVerifyDeleteAsync(
|
||||
tableName,
|
||||
filter,
|
||||
expectedAnchor: $"RowKey={normalizedTenantId}",
|
||||
verifyRow: e => string.Equals(e.RowKey, normalizedTenantId, StringComparison.Ordinal),
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blob-prefix wipe. Lists blobs under <c>{normalizedTenantId}/</c> in the named
|
||||
/// container, verifies each returned name starts with the prefix, then deletes
|
||||
|
||||
+21
-1
@@ -56,7 +56,6 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
|
||||
private static readonly string[] TenantPartitionTables =
|
||||
{
|
||||
Constants.TableNames.AuditLogs,
|
||||
Constants.TableNames.UsageMetrics,
|
||||
Constants.TableNames.UserActivity,
|
||||
Constants.TableNames.UserPresence,
|
||||
Constants.TableNames.AppInstallSummaries,
|
||||
@@ -93,6 +92,22 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
|
||||
// are customer-entered notes tied to the tenant's sessions, not product feedback.
|
||||
// Rows also die per-session via the deletion manifest; this covers offboarding.
|
||||
Constants.TableNames.SessionAnnotations,
|
||||
// Cumulative per-tenant counters (PK=tenantId, RK="current"). The platform row
|
||||
// lives under PK="global", which can never match a tenant GUID, so the exact-PK
|
||||
// wipe removes only the offboarded tenant's partition.
|
||||
Constants.TableNames.PlatformStats,
|
||||
// Intune script display-name cache (PK=tenantId). Only had TTL eviction before;
|
||||
// tenant script names must not outlive the tenant.
|
||||
Constants.TableNames.ScriptNameCache,
|
||||
};
|
||||
|
||||
// Variant D — RowKey-anchored wipes for tables whose ROW key is the tenant id.
|
||||
// UsageMetrics rows are written as PK=date ("yyyy-MM-dd"), RK=tenantId|"global"
|
||||
// (SaveUsageMetricsSnapshotAsync) — an exact-PK wipe here matched 0 rows, so
|
||||
// per-tenant snapshots silently survived offboarding until the 180d retention sweep.
|
||||
private static readonly string[] RowKeyTables =
|
||||
{
|
||||
Constants.TableNames.UsageMetrics,
|
||||
};
|
||||
|
||||
// Plan §6.4 — composite-PK "{tenantId}_..." wipes (Variant A range).
|
||||
@@ -956,6 +971,11 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
|
||||
ct.ThrowIfCancellationRequested();
|
||||
counts[table] = await _safeWipe.WipeByTenantIdPropertyAsync(table, tenantId, ct);
|
||||
}
|
||||
foreach (var table in RowKeyTables)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
counts[table] = await _safeWipe.WipeByRowKeyAsync(table, tenantId, ct);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Failure path ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// The declared SessionsIndex column set — single source for what the FULL-MIRROR index
|
||||
/// row carries (see <c>TableStorageService.BuildSessionIndexEntity</c>, which is pinned
|
||||
/// against this manifest bidirectionally by <c>SessionIndexFieldManifestTests</c>).
|
||||
///
|
||||
/// Maintenance flow for a NEW mirrored field:
|
||||
/// 1. add it to <see cref="AlwaysProjected"/> (written with a default on every rebuild)
|
||||
/// or <see cref="ConditionallyProjected"/> (written only when present),
|
||||
/// 2. write it in <c>BuildSessionIndexEntity</c> with matching semantics,
|
||||
/// 3. read it in <c>MapToSessionSummary</c>.
|
||||
/// Skipping any step turns the manifest tests red. Merge sites are guarded fail-soft:
|
||||
/// <c>MergeSessionIndexAsync</c> logs a warning for keys outside <see cref="All"/> —
|
||||
/// a merged-but-not-projected field is exactly the recurring StartedAt-shift drift bug
|
||||
/// (e.g. ab90423b).
|
||||
/// </summary>
|
||||
internal static class SessionIndexFieldManifest
|
||||
{
|
||||
/// <summary>
|
||||
/// Written on every index rebuild with a default (never absent from an index row).
|
||||
/// Note: <c>CurrentPhaseDetail</c> is a dead column — no Sessions writer sets it, so
|
||||
/// it is always empty; kept for row-shape stability until deliberately retired.
|
||||
/// </summary>
|
||||
public static readonly string[] AlwaysProjected =
|
||||
{
|
||||
"SessionId",
|
||||
"SerialNumber",
|
||||
"DeviceName",
|
||||
"Manufacturer",
|
||||
"Model",
|
||||
"StartedAt",
|
||||
"Status",
|
||||
"CurrentPhase",
|
||||
"CurrentPhaseDetail",
|
||||
"EventCount",
|
||||
"EnrollmentType",
|
||||
"IsPreProvisioned",
|
||||
"IsHybridJoin",
|
||||
"IsUserDriven",
|
||||
"IsSelfDeployingProfile",
|
||||
"IsCloudPc",
|
||||
"AgentVersion",
|
||||
"ImeAgentVersion",
|
||||
"OsName",
|
||||
"OsBuild",
|
||||
"OsDisplayVersion",
|
||||
"OsEdition",
|
||||
"OsLanguage",
|
||||
"GeoCountry",
|
||||
"GeoRegion",
|
||||
"GeoCity",
|
||||
"GeoLoc",
|
||||
"PlatformScriptCount",
|
||||
"RemediationScriptCount",
|
||||
"RebootCount",
|
||||
"ExcessiveEventsAlerted",
|
||||
"ExcessiveEventsAutoActioned",
|
||||
};
|
||||
|
||||
/// <summary>Written only when the Sessions row carries a value (absent otherwise).</summary>
|
||||
public static readonly string[] ConditionallyProjected =
|
||||
{
|
||||
"CompletedAt",
|
||||
"FailureReason",
|
||||
"FailureSource",
|
||||
"ReconcileReason",
|
||||
"EspSoftFailure",
|
||||
"CompletionSource",
|
||||
"ValidatedBy",
|
||||
"FailureSnapshotJson",
|
||||
"AdminMarkedAction",
|
||||
"DurationSeconds",
|
||||
"DiagnosticsBlobName",
|
||||
"DiagnosticsBlobDestination",
|
||||
"LastEventAt",
|
||||
"ResumedAt",
|
||||
"StalledAt",
|
||||
"AvgApiLatencyMs",
|
||||
"ApiRequestCount",
|
||||
"ConnectionType",
|
||||
};
|
||||
|
||||
/// <summary>Every column an index row may carry (data columns; PK/RK excluded).</summary>
|
||||
public static readonly string[] All =
|
||||
AlwaysProjected.Concat(ConditionallyProjected).ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Sessions-row fields owned by separate write subsystems that deliberately do NOT
|
||||
/// touch the index (ServerActions queue + deletion CAS). Sessions served from the
|
||||
/// index read these as defaults; routing them through the index sync is a tracked
|
||||
/// follow-up, not an accident.
|
||||
/// </summary>
|
||||
public static readonly string[] PrimaryOnly =
|
||||
{
|
||||
"PendingActionsJson",
|
||||
"PendingActionsQueuedAt",
|
||||
"DeletionState",
|
||||
"PendingDeletionManifestId",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> AllSet = new(All, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// System keys a merge entity may legitimately carry besides data columns.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> SystemKeys = new(StringComparer.Ordinal)
|
||||
{
|
||||
"PartitionKey", "RowKey", "Timestamp", "odata.etag",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns the data keys of <paramref name="mergeEntity"/> that are NOT part of the
|
||||
/// manifest — i.e. fields a merge site writes that a StartedAt-shift full rebuild
|
||||
/// would silently drop. Callers log these as a warning (fail-soft).
|
||||
/// </summary>
|
||||
public static List<string> FindNonManifestKeys(Azure.Data.Tables.TableEntity mergeEntity)
|
||||
{
|
||||
List<string>? offenders = null;
|
||||
foreach (var kvp in mergeEntity)
|
||||
{
|
||||
if (SystemKeys.Contains(kvp.Key) || AllSet.Contains(kvp.Key))
|
||||
continue;
|
||||
(offenders ??= new List<string>()).Add(kvp.Key);
|
||||
}
|
||||
return offenders ?? new List<string>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -507,28 +507,50 @@ namespace AutopilotMonitor.Functions.Services
|
||||
: await SearchSessionsByScanPageAsync(tenantId, filter, pageSize, continuation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cap on the number of Azure pages one free-text (<c>q=</c>) request may walk while
|
||||
/// backfilling matches. Free-text matches can be very sparse (one serial in a large
|
||||
/// tenant), so without backfill every response would be a near-empty page and the
|
||||
/// client would pay one round-trip per Azure page — the filter-after-pagination trap.
|
||||
/// When the cap is hit the accumulated matches return with a nextLink so the caller
|
||||
/// continues from an exact page boundary (no gaps).
|
||||
/// </summary>
|
||||
internal const int FreeTextBackfillMaxRounds = 10;
|
||||
|
||||
private async Task<RawPage<SessionSummary>> SearchSessionsByScanPageAsync(
|
||||
string? tenantId, SessionSearchFilter filter, int pageSize, string? continuation)
|
||||
{
|
||||
var indexTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex);
|
||||
var oDataFilter = BuildSearchScanFilter(tenantId, filter);
|
||||
|
||||
var (entities, nextRawToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
||||
client: indexTableClient,
|
||||
filter: oDataFilter,
|
||||
pageSize: pageSize,
|
||||
continuation: continuation);
|
||||
// Free-text queries backfill whole Azure pages until pageSize matches accumulated
|
||||
// (bounded rounds); non-q queries keep the single-page contract ("consume until
|
||||
// absent" — a page may contain fewer than pageSize items after client filters).
|
||||
var maxRounds = string.IsNullOrEmpty(filter.Q) ? 1 : FreeTextBackfillMaxRounds;
|
||||
|
||||
var sessions = new List<SessionSummary>(entities.Count);
|
||||
foreach (var entity in entities)
|
||||
var sessions = new List<SessionSummary>(pageSize);
|
||||
var token = continuation;
|
||||
string? nextRawToken = null;
|
||||
for (var round = 0; round < maxRounds; round++)
|
||||
{
|
||||
var session = MapIndexEntityToSessionSummary(entity);
|
||||
if (!MatchesScanClientFilters(session, filter)) continue;
|
||||
sessions.Add(session);
|
||||
var (entities, next) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
||||
client: indexTableClient,
|
||||
filter: oDataFilter,
|
||||
pageSize: pageSize,
|
||||
continuation: token);
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
var session = MapIndexEntityToSessionSummary(entity);
|
||||
if (!MatchesScanClientFilters(session, filter)) continue;
|
||||
sessions.Add(session);
|
||||
}
|
||||
|
||||
nextRawToken = next;
|
||||
token = next;
|
||||
if (token == null || sessions.Count >= pageSize)
|
||||
break;
|
||||
}
|
||||
// Note: a page may legitimately contain fewer than pageSize items after
|
||||
// client-side filters; callers should follow nextLink until absent for
|
||||
// forensics-grade exact-count semantics (Plan §"consume until absent").
|
||||
return new RawPage<SessionSummary>(sessions, nextRawToken);
|
||||
}
|
||||
|
||||
@@ -688,8 +710,41 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return string.Equals(session.ConnectionType, filter.ConnectionType, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Free-text predicate for the dashboard search box (<c>q=</c>). Case-insensitive
|
||||
/// substring over EXACTLY the fields the web dashboard's client-side filter searches
|
||||
/// (useDashboardFilters.searchableText, minus its derived-only tokens: localized date
|
||||
/// string, "N min" duration, "blocked"). Keep the two lists in sync — a field matched
|
||||
/// only server-side would be filtered back out by the client and read as a ghost result.
|
||||
/// </summary>
|
||||
internal static bool MatchesFreeText(SessionSummary session, string? q)
|
||||
{
|
||||
if (string.IsNullOrEmpty(q)) return true;
|
||||
|
||||
return ContainsIgnoreCase(session.DeviceName, q)
|
||||
|| ContainsIgnoreCase(session.SerialNumber, q)
|
||||
|| ContainsIgnoreCase(session.Manufacturer, q)
|
||||
|| ContainsIgnoreCase(session.Model, q)
|
||||
|| ContainsIgnoreCase(session.Status.ToString(), q)
|
||||
|| ContainsIgnoreCase(session.SessionId, q)
|
||||
|| ContainsIgnoreCase(session.GeoCountry, q)
|
||||
|| ContainsIgnoreCase(session.GeoRegion, q)
|
||||
|| ContainsIgnoreCase(session.GeoCity, q)
|
||||
|| ContainsIgnoreCase(session.AgentVersion, q)
|
||||
|| ContainsIgnoreCase(session.OsName, q)
|
||||
|| ContainsIgnoreCase(session.OsBuild, q)
|
||||
|| ContainsIgnoreCase(session.OsDisplayVersion, q)
|
||||
|| ContainsIgnoreCase(session.OsEdition, q)
|
||||
|| ContainsIgnoreCase(session.OsLanguage, q);
|
||||
}
|
||||
|
||||
private static bool ContainsIgnoreCase(string? haystack, string needle)
|
||||
=> !string.IsNullOrEmpty(haystack)
|
||||
&& haystack!.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
|
||||
private static bool MatchesScanClientFilters(SessionSummary session, SessionSearchFilter filter)
|
||||
{
|
||||
if (!MatchesFreeText(session, filter.Q)) return false;
|
||||
if (!string.IsNullOrEmpty(filter.SerialNumber) &&
|
||||
!string.Equals(session.SerialNumber, filter.SerialNumber, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
@@ -978,6 +1033,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// query can't return non-matching sessions.
|
||||
if (!MatchesRebootCountBounds(s, filter)) return false;
|
||||
if (!MatchesConnectionType(s, filter)) return false;
|
||||
if (!MatchesFreeText(s, filter.Q)) return false;
|
||||
return true;
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
@@ -49,7 +49,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// back in the Sessions entity. Copies all SessionSummary-relevant fields so that
|
||||
/// listing queries only need to hit the index table.
|
||||
/// </summary>
|
||||
private async Task UpsertSessionIndexAsync(TableEntity sessionEntity, DateTime startedAt)
|
||||
// internal (not private) so SessionIndexFieldManifestTests can pin the Replace-mode
|
||||
// full-rebuild semantics + the stale-RowKey delete without a live table service.
|
||||
internal async Task UpsertSessionIndexAsync(TableEntity sessionEntity, DateTime startedAt)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -77,7 +79,11 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// Build index entity from the single SessionsIndex field manifest (lean read-model contract).
|
||||
var indexEntity = BuildSessionIndexEntity(sessionEntity, indexRowKey, startedAt);
|
||||
|
||||
await indexTableClient.UpsertEntityAsync(indexEntity);
|
||||
// Replace, not merge: the full rebuild is the mirror's truth. Merge-mode upsert
|
||||
// could not clear conditional fields the primary meanwhile blanked (e.g. a
|
||||
// FailureReason cleared on re-Succeed) — the stale column would survive every
|
||||
// non-shift rebuild (re-registration, backfill) forever.
|
||||
await indexTableClient.UpsertEntityAsync(indexEntity, TableUpdateMode.Replace);
|
||||
|
||||
// Store IndexRowKey back in the Sessions entity so Merge-mode updates can find it
|
||||
var sessionsTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.Sessions);
|
||||
@@ -100,10 +106,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// do). It serves the session list / search / stats AND the <c>/api/raw/sessions</c> endpoint
|
||||
/// (which returns every stored index column verbatim, see <c>RawEntityProjection</c>) directly
|
||||
/// — no hydration from Sessions — so it must carry the same column set the session lifecycle
|
||||
/// writes. This builder is the ONE place that set is defined; it must stay a SUPERSET of every
|
||||
/// field any <c>MergeSessionIndexAsync</c> call site writes, otherwise a StartedAt-shift full
|
||||
/// upsert would drop a merged field until the next merge (the recurring drift bug, e.g.
|
||||
/// ab90423b). Keep aligned with <c>MapToSessionSummary</c> (read side).
|
||||
/// writes. The declared column set lives in <see cref="SessionIndexFieldManifest"/>; this
|
||||
/// builder is pinned against it bidirectionally by <c>SessionIndexFieldManifestTests</c> and
|
||||
/// must stay a SUPERSET of every field any <c>MergeSessionIndexAsync</c> call site writes
|
||||
/// (guarded fail-soft there), otherwise a StartedAt-shift full upsert would drop a merged
|
||||
/// field until the next merge (the recurring drift bug, e.g. ab90423b). Keep aligned with
|
||||
/// <c>MapToSessionSummary</c> (read side — also pinned by the manifest tests).
|
||||
///
|
||||
/// Not mirrored here: fields owned by separate write subsystems that do not touch the index —
|
||||
/// <c>PendingActionsJson</c>/<c>PendingActionsQueuedAt</c> (ServerActions) and
|
||||
@@ -265,6 +273,17 @@ namespace AutopilotMonitor.Functions.Services
|
||||
indexUpdate[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
// Fail-soft drift guard: a merged field missing from the manifest (and thus
|
||||
// from BuildSessionIndexEntity) is silently dropped on the next StartedAt-shift
|
||||
// full rebuild — the recurring drift bug. Write it anyway, but surface the gap.
|
||||
var nonManifestKeys = SessionIndexFieldManifest.FindNonManifestKeys(indexUpdate);
|
||||
if (nonManifestKeys.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"SessionsIndex merge for {TenantId}/{IndexRowKey} writes field(s) outside SessionIndexFieldManifest: {Fields} — add them to the manifest + BuildSessionIndexEntity or a full rebuild will drop them",
|
||||
tenantId, indexRowKey, string.Join(", ", nonManifestKeys));
|
||||
}
|
||||
|
||||
await indexTableClient.UpdateEntityAsync(indexUpdate, ETag.All, TableUpdateMode.Merge);
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
@@ -730,6 +749,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// Dual-write: upsert into SessionsIndex for time-sorted listing
|
||||
await UpsertSessionIndexAsync(entity, startedAt);
|
||||
|
||||
// Dual-write: sessionId → tenantId point-read lookup (fail-soft; legacy
|
||||
// sessions heal lazily in ResolveSessionTenantIdAsync).
|
||||
await UpsertSessionTenantLookupAsync(registration.SessionId, registration.TenantId);
|
||||
|
||||
_logger.LogInformation($"Stored session {registration.SessionId}");
|
||||
return true;
|
||||
}
|
||||
@@ -1497,14 +1520,52 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>RowKey of the single lookup row per session in <see cref="Constants.TableNames.SessionTenantLookup"/>.</summary>
|
||||
private const string SessionTenantLookupRowKey = "tenant";
|
||||
|
||||
/// <summary>
|
||||
/// Finds the tenantId for a session by scanning SessionsIndex.
|
||||
/// Used for Global Admin cross-tenant session lookup when tenantId is unknown.
|
||||
/// Resolves the tenantId owning a session. Point-read on the SessionTenantLookup table
|
||||
/// (PK=sessionId); sessions registered before the table existed fall back to the legacy
|
||||
/// cross-partition SessionsIndex scan and self-heal by writing the lookup row, so the
|
||||
/// scan cost is paid at most once per legacy session. Used for Global Admin cross-tenant
|
||||
/// session access when tenantId is unknown.
|
||||
/// </summary>
|
||||
public async Task<string?> FindSessionTenantIdAsync(string sessionId)
|
||||
public async Task<string?> ResolveSessionTenantIdAsync(string sessionId)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
|
||||
|
||||
try
|
||||
{
|
||||
var lookupClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionTenantLookup);
|
||||
var response = await lookupClient.GetEntityAsync<TableEntity>(sessionId, SessionTenantLookupRowKey);
|
||||
var tenantId = response.Value.GetString("TenantId");
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
return tenantId;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Legacy session (pre-lookup-table) or already deleted — fall through to the scan.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "SessionTenantLookup point-read failed for {SessionId}, falling back to index scan", sessionId);
|
||||
}
|
||||
|
||||
var scanned = await ScanSessionsIndexForTenantIdAsync(sessionId);
|
||||
if (scanned != null)
|
||||
{
|
||||
// Self-heal: next resolve for this legacy session is a point-read.
|
||||
await UpsertSessionTenantLookupAsync(sessionId, scanned);
|
||||
}
|
||||
return scanned;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legacy fallback: finds the tenantId by scanning SessionsIndex cross-partition
|
||||
/// (SessionId is a non-key property there). Reserve for lookup-table misses.
|
||||
/// </summary>
|
||||
private async Task<string?> ScanSessionsIndexForTenantIdAsync(string sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var indexTableClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionsIndex);
|
||||
@@ -1523,6 +1584,27 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the sessionId → tenantId lookup row (fail-soft: resolution still works via
|
||||
/// the scan fallback when this write is lost).
|
||||
/// </summary>
|
||||
private async Task UpsertSessionTenantLookupAsync(string sessionId, string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var lookupClient = _tableServiceClient.GetTableClient(Constants.TableNames.SessionTenantLookup);
|
||||
var row = new TableEntity(sessionId, SessionTenantLookupRowKey)
|
||||
{
|
||||
["TenantId"] = tenantId
|
||||
};
|
||||
await lookupClient.UpsertEntityAsync(row);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to upsert SessionTenantLookup for {SessionId}", sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the session status and current phase.
|
||||
/// Uses Merge mode to write only changed fields, reducing ETag conflicts under concurrency.
|
||||
@@ -2116,6 +2198,21 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (resumedAt.HasValue)
|
||||
forceUpdate["ResumedAt"] = EnsureUtc(resumedAt.Value);
|
||||
|
||||
// Mirror the stalled-state bookkeeping + heal from the normal path —
|
||||
// otherwise the force-merge fallback drops a stall marker (or keeps a
|
||||
// healed session visually stalled) exactly when ETag retries are
|
||||
// exhausted at the stall/heal transition.
|
||||
if (stalledAt.HasValue)
|
||||
{
|
||||
forceUpdate["StalledAt"] = EnsureUtc(stalledAt.Value);
|
||||
if (!string.IsNullOrEmpty(failureReason))
|
||||
forceUpdate["FailureReason"] = failureReason;
|
||||
}
|
||||
if (clearStalledAt)
|
||||
forceUpdate["StalledAt"] = (DateTime?)null;
|
||||
if (clearFailureReason)
|
||||
forceUpdate["FailureReason"] = string.Empty;
|
||||
|
||||
// Unconditional merge write — ETag.All bypasses concurrency check
|
||||
await forceTableClient.UpdateEntityAsync(forceUpdate, ETag.All, TableUpdateMode.Merge);
|
||||
|
||||
|
||||
@@ -1119,6 +1119,16 @@ namespace AutopilotMonitor.Shared
|
||||
// annotations are hand-labeled rule-quality data.
|
||||
public const string SessionAnnotations = "SessionAnnotations";
|
||||
|
||||
// Point-read lookup sessionId → tenantId (Fragilitätsaudit P6.1). Replaces the
|
||||
// cross-partition SessionsIndex scan the global-scope endpoints paid to resolve a
|
||||
// session's owning tenant.
|
||||
// - PartitionKey = sessionId, RowKey = "tenant", property TenantId
|
||||
// Written on session registration; legacy sessions are healed lazily (scan
|
||||
// fallback writes the row on first resolve). Cascade-deleted with the session
|
||||
// (manifest step right before the tombstone); rebuildable from Sessions, so
|
||||
// NOT in the critical-backup set.
|
||||
public const string SessionTenantLookup = "SessionTenantLookup";
|
||||
|
||||
/// <summary>
|
||||
/// Returns all table names for initialization
|
||||
/// </summary>
|
||||
@@ -1188,6 +1198,7 @@ namespace AutopilotMonitor.Shared
|
||||
BackupJobs,
|
||||
ConfigurationBackups,
|
||||
SessionAnnotations,
|
||||
SessionTenantLookup,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,13 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
// --- Session CRUD ---
|
||||
Task<bool> StoreSessionAsync(SessionRegistration registration);
|
||||
Task<SessionSummary?> GetSessionAsync(string tenantId, string sessionId);
|
||||
Task<string?> FindSessionTenantIdAsync(string sessionId);
|
||||
/// <summary>
|
||||
/// Resolves the tenantId owning <paramref name="sessionId"/> — point-read on the
|
||||
/// SessionTenantLookup table with a legacy SessionsIndex-scan fallback that self-heals
|
||||
/// the lookup row. Null when the session is unknown. Used for global-scope cross-tenant
|
||||
/// session access when tenantId is not supplied (see RequestContextExtensions.ResolveSessionScopeAsync).
|
||||
/// </summary>
|
||||
Task<string?> ResolveSessionTenantIdAsync(string sessionId);
|
||||
/// <summary>
|
||||
/// Returns all sessions for <paramref name="tenantId"/> newest-first.
|
||||
/// Optional <paramref name="days"/> scopes to the last N days. No row cap —
|
||||
|
||||
@@ -43,6 +43,14 @@ namespace AutopilotMonitor.Shared.Models
|
||||
// property and are excluded (acceptable — they have no connection data).
|
||||
public string? ConnectionType { get; set; }
|
||||
|
||||
// Free-text search (dashboard search box). Case-insensitive substring match,
|
||||
// applied server-side AFTER the OData scan, across the same field set the web
|
||||
// dashboard's client-side filter uses (device name, serial, manufacturer, model,
|
||||
// status, sessionId, geo, agent version, OS fields). Cannot be pushed to OData —
|
||||
// Azure Tables has no substring operator — so the scan path backfills pages
|
||||
// until it has pageSize matches (bounded rounds).
|
||||
public string? Q { get; set; }
|
||||
|
||||
public int Limit { get; set; } = 50;
|
||||
|
||||
// Dynamic device property filters (key = "eventType.propertyName", value = filter expression)
|
||||
|
||||
@@ -79,6 +79,8 @@ interface SessionTableProps {
|
||||
loadingMore: boolean;
|
||||
loadingAll: boolean;
|
||||
onLoadAll: () => void;
|
||||
/** Server-side search sweep for the current search query (dropdown "Search all sessions"). */
|
||||
onSearchAll: () => void;
|
||||
adminMode: boolean;
|
||||
globalAdminMode: boolean;
|
||||
tenantIdFilter: string;
|
||||
@@ -130,6 +132,7 @@ export function SessionTable({
|
||||
loadingMore,
|
||||
loadingAll,
|
||||
onLoadAll,
|
||||
onSearchAll,
|
||||
adminMode,
|
||||
globalAdminMode,
|
||||
tenantIdFilter,
|
||||
@@ -558,7 +561,7 @@ export function SessionTable({
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowSearchSuggestions(false);
|
||||
onLoadAll();
|
||||
onSearchAll();
|
||||
}}
|
||||
className="text-xs font-medium text-green-700 hover:text-green-800 transition-colors"
|
||||
>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"
|
||||
import { extractContinuation } from "@/lib/paginationLink";
|
||||
import { asGuidOrUndefined } from "@/utils/inputValidation";
|
||||
import { boundTenantToDelegatedScope } from "@/utils/delegatedScope";
|
||||
import { mergeSessionsById } from "@/lib/sessionSearchMerge";
|
||||
import { isHomeTenantTarget } from "@/utils/homeTenantScope";
|
||||
import { hasTenantReadScope } from "@/lib/tenantScope";
|
||||
import type { NotificationType } from "@/contexts/NotificationContext";
|
||||
@@ -15,6 +16,14 @@ import type { Session } from "../types";
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
const MAX_PAGE_SIZE = 1000;
|
||||
|
||||
// Server-side search sweep bounds: the backend scans up to 10 Azure pages per request
|
||||
// (free-text backfill) and returns only matches; the client follows nextLink until it has
|
||||
// a comfortable result set or the cap is reached — instead of the former loadAll() walk
|
||||
// that downloaded the ENTIRE session history to search it client-side.
|
||||
const SEARCH_PAGE_SIZE = 100;
|
||||
const SEARCH_MAX_REQUESTS = 10;
|
||||
const SEARCH_TARGET_MATCHES = 50;
|
||||
|
||||
type AddNotification = (
|
||||
type: NotificationType,
|
||||
title: string,
|
||||
@@ -74,6 +83,13 @@ export interface UseDashboardSessionsReturn {
|
||||
refetchWith: (tenantIdOverride: string) => void;
|
||||
loadMore: () => void;
|
||||
loadAll: () => void;
|
||||
/**
|
||||
* Server-side search sweep for the dashboard search box: fetches only sessions matching
|
||||
* the query (backend `q=` filter) and merges them into the loaded list, instead of
|
||||
* downloading the whole history like loadAll(). Falls back to loadAll() for delegated
|
||||
* (MSP) callers in cross-tenant mode — the global search route has no delegated tier.
|
||||
*/
|
||||
searchAll: (query: string) => void;
|
||||
removeSession: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
@@ -367,6 +383,70 @@ export function useDashboardSessions({
|
||||
}
|
||||
}, [fetchSessionsBatch, continuationRef]);
|
||||
|
||||
// Server-side search sweep — see UseDashboardSessionsReturn.searchAll. Shares the
|
||||
// fetchLock/loadAllToken discipline with loadAll() so refetch/unmount cancels it and
|
||||
// the two sweeps can never interleave.
|
||||
const searchAll = useCallback(async (query: string) => {
|
||||
const q = query.trim();
|
||||
if (q.length < 2 || fetchLockRef.current) return;
|
||||
|
||||
const rawFilter = submittedTenantIdFilterRef.current.trim();
|
||||
const homeSelected = isDelegatedScopeRef.current &&
|
||||
isHomeTenantTarget(asGuidOrUndefined(rawFilter), tenantIdRef.current ?? undefined);
|
||||
const useGlobal = globalAdminModeRef.current && !homeSelected;
|
||||
|
||||
// Delegated (MSP) callers in cross-tenant mode have no access to the global search
|
||||
// route (GlobalReadOrAdmin) — keep their previous full-sweep behavior.
|
||||
if (useGlobal && isDelegatedScopeRef.current) {
|
||||
await loadAll();
|
||||
return;
|
||||
}
|
||||
|
||||
fetchLockRef.current = true;
|
||||
const myToken = ++loadAllTokenRef.current;
|
||||
setLoadingMore(true);
|
||||
setLoadingAll(true);
|
||||
try {
|
||||
const effectiveTenantFilter = boundTenantToDelegatedScope(
|
||||
asGuidOrUndefined(rawFilter), isDelegatedScopeRef.current, delegatedTenantIdsRef.current);
|
||||
|
||||
let continuation: string | undefined;
|
||||
let matches = 0;
|
||||
for (let round = 0; round < SEARCH_MAX_REQUESTS && loadAllTokenRef.current === myToken; round++) {
|
||||
const endpoint = useGlobal
|
||||
? api.globalSessions.search(q, effectiveTenantFilter, { pageSize: SEARCH_PAGE_SIZE, continuation })
|
||||
: api.sessions.search(q, { pageSize: SEARCH_PAGE_SIZE, continuation });
|
||||
const response = await authenticatedFetch(endpoint, getAccessToken);
|
||||
if (!response.ok) {
|
||||
console.error(`Server-side session search failed (${response.status})`);
|
||||
break;
|
||||
}
|
||||
const data = await response.json();
|
||||
if (loadAllTokenRef.current !== myToken) break;
|
||||
|
||||
const found: Session[] = data.sessions || [];
|
||||
if (found.length > 0) {
|
||||
matches += found.length;
|
||||
setSessions((prev) => mergeSessionsById(prev, found));
|
||||
}
|
||||
|
||||
const next = extractContinuation(data.nextLink);
|
||||
if (!next || matches >= SEARCH_TARGET_MATCHES) break;
|
||||
continuation = next;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TokenExpiredError) {
|
||||
addNotification("error", "Session Expired", error.message, "session-expired-error");
|
||||
} else {
|
||||
console.error("Server-side session search failed:", error);
|
||||
}
|
||||
} finally {
|
||||
fetchLockRef.current = false;
|
||||
setLoadingMore(false);
|
||||
setLoadingAll(false);
|
||||
}
|
||||
}, [getAccessToken, addNotification, loadAll, globalAdminModeRef, tenantIdRef, isDelegatedScopeRef, delegatedTenantIdsRef]);
|
||||
|
||||
const removeSession = useCallback((sessionId: string) => {
|
||||
setSessions((prev) => prev.filter((s) => s.sessionId !== sessionId));
|
||||
}, []);
|
||||
@@ -538,6 +618,7 @@ export function useDashboardSessions({
|
||||
refetchWith,
|
||||
loadMore,
|
||||
loadAll,
|
||||
searchAll,
|
||||
removeSession,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ function HomeContent() {
|
||||
|
||||
const {
|
||||
sessions, loading, hasMore, loadingMore, loadingAll,
|
||||
refetch, refetchWith, loadMore, loadAll, removeSession,
|
||||
refetch, refetchWith, loadMore, loadAll, searchAll, removeSession,
|
||||
} = useDashboardSessions({
|
||||
user, tenantId, globalAdminMode: crossTenant, joinGlobalAdmins, tenantIdFilter, adminMode,
|
||||
getAccessToken, addNotification, setBlockedDevicesSet, signalR,
|
||||
@@ -287,7 +287,8 @@ function HomeContent() {
|
||||
}
|
||||
}, [sessions.length, currentPage, sessionsPerPage, hasMore, loading, loadingMore, loadMore]);
|
||||
|
||||
// Auto-load ALL remaining sessions when search is active and local results are insufficient.
|
||||
// Server-search when search is active and local results are insufficient — fetches only
|
||||
// matching sessions (backend q= filter) instead of the former loadAll() full-history walk.
|
||||
// Uses a 500ms debounce so rapid typing doesn't trigger unnecessary loads.
|
||||
const autoLoadTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
@@ -301,11 +302,11 @@ function HomeContent() {
|
||||
if (filteredSessions.length >= 8) return;
|
||||
|
||||
autoLoadTimerRef.current = setTimeout(() => {
|
||||
loadAll();
|
||||
searchAll(query);
|
||||
}, 500);
|
||||
|
||||
return () => { if (autoLoadTimerRef.current) clearTimeout(autoLoadTimerRef.current); };
|
||||
}, [searchQuery, filteredSessions.length, hasMore, loading, loadingMore, loadAll]);
|
||||
}, [searchQuery, filteredSessions.length, hasMore, loading, loadingMore, searchAll]);
|
||||
|
||||
const applyTenantIdFilter = (value: string) => {
|
||||
setTenantIdFilter(value);
|
||||
@@ -562,6 +563,7 @@ function HomeContent() {
|
||||
loadingMore={loadingMore}
|
||||
loadingAll={loadingAll}
|
||||
onLoadAll={loadAll}
|
||||
onSearchAll={() => searchAll(searchQuery)}
|
||||
adminMode={adminMode}
|
||||
globalAdminMode={crossTenant}
|
||||
tenantIdFilter={tenantIdFilter}
|
||||
|
||||
@@ -70,7 +70,7 @@ function DiagnosisContent() {
|
||||
try {
|
||||
// Fetch the single session directly instead of pulling the entire session
|
||||
// list and .find()-ing client-side. The backend resolves the tenant via
|
||||
// FindSessionTenantIdAsync for global admins when tenantId is omitted.
|
||||
// ResolveSessionTenantIdAsync for global admins when tenantId is omitted.
|
||||
const knownTenantId = sessionTenantId || (!globalAdminMode ? tenantId : null);
|
||||
const endpoint =
|
||||
knownTenantId && isGuid(knownTenantId)
|
||||
|
||||
@@ -100,7 +100,7 @@ export function useSessionDetail({
|
||||
try {
|
||||
const knownTenantId = resolveEffectiveTenantId();
|
||||
// Always use the direct session endpoint — the backend resolves the tenant
|
||||
// via FindSessionTenantIdAsync for global admins when tenantId is unknown.
|
||||
// via ResolveSessionTenantIdAsync for global admins when tenantId is unknown.
|
||||
const endpoint = knownTenantId
|
||||
? api.sessions.get(sessionId, knownTenantId)
|
||||
: api.sessions.get(sessionId);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type SetStateAction } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type SetStateAction } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTenant } from "../../contexts/TenantContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
@@ -1628,8 +1628,13 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
// -----------------------------------------------------------------------
|
||||
// Provider value
|
||||
// -----------------------------------------------------------------------
|
||||
return (
|
||||
<TenantConfigContext.Provider value={{
|
||||
// Memoized (P6.3): the provider re-renders whenever ANY upstream context emits
|
||||
// (Auth account refresh, a notification anywhere in the app, tenant/theme) — without
|
||||
// the memo every such render minted a fresh value object and re-rendered all 16
|
||||
// settings sections for nothing. Own-state changes still fan out (the value honestly
|
||||
// depends on nearly every field); the memo only cuts the externally-triggered cascade.
|
||||
// The dependency array is enforced by react-hooks/exhaustive-deps (lint cap = 0).
|
||||
const value = useMemo<TenantConfigContextValue>(() => ({
|
||||
config, loading, canEditConfig, savingSection,
|
||||
error, setError, successMessage, setSuccessMessage,
|
||||
|
||||
@@ -1745,7 +1750,47 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
|
||||
// Auth
|
||||
user, getAccessToken,
|
||||
}}>
|
||||
}), [
|
||||
config, loading, canEditConfig, savingSection, error, successMessage,
|
||||
editionInfo, startingTrial, startTrial, appHomingFunnelActive, homingFlipped,
|
||||
validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation,
|
||||
handleToggleDeviceAssociationValidation, validateCloudPcDevice, handleToggleCloudPcValidation,
|
||||
saveValidationGate, autopilotConsentInProgress, beginDeviceValidationConsentFlow, detectExistingAccess,
|
||||
manufacturerWhitelist, modelWhitelist, effectiveHwRejectionNotify, setHwRejectionNotifyWriteThrough,
|
||||
handleSaveHardwareWhitelist, handleResetHardwareWhitelist,
|
||||
enablePerformanceCollector, performanceCollectorInterval, helloWaitTimeoutSeconds,
|
||||
selfDestructOnComplete, keepLogFile, rebootOnComplete, rebootDelaySeconds,
|
||||
contactEmail, handleSaveContact, handleResetContact,
|
||||
enableGeoLocation, enableTimezoneAutoSet, enableImeMatchLog, enableGatherRuleDebugLog,
|
||||
logLevel, showScriptOutput, showEnrollmentSummary, enrollmentSummaryTimeoutSeconds,
|
||||
enrollmentSummaryBrandingImageUrl, enrollmentSummaryLaunchRetrySeconds,
|
||||
handleSaveAgentSettings, handleResetAgentSettings,
|
||||
enableLocalAdminAnalyzer, localAdminAllowedAccounts, newAllowedAccount,
|
||||
enableSoftwareInventoryAnalyzer, enableIntegrityBypassAnalyzer, enableRealmJoinWatcher,
|
||||
keepAwakeDuringUserEsp, enableConsoleBypassDetection,
|
||||
handleSaveAgentAnalyzers, handleResetAgentAnalyzers,
|
||||
unrestrictedMode, handleSaveUnrestrictedMode,
|
||||
notificationChannels, testingChannelId, testChannelResult,
|
||||
handleTestChannel, handleSaveNotifications, handleResetNotifications,
|
||||
slaTargetSuccessRate, slaTargetMaxDurationMinutes, slaTargetAppInstallSuccessRate,
|
||||
slaNotifyOnSuccessRateBreach, slaSuccessRateNotifyThreshold, slaNotifyOnDurationBreach,
|
||||
slaNotifyOnAppInstallBreach, slaNotifyOnConsecutiveFailures, slaConsecutiveFailureThreshold,
|
||||
handleSaveSlaTargets, handleResetSlaTargets,
|
||||
diagnosticsBlobSasUrl, diagnosticsUploadMode, diagnosticsUploadDestination,
|
||||
tenantDiagPaths, globalDiagPaths, newDiagPath, newDiagDesc,
|
||||
handleSaveDiagnostics, handleResetDiagnostics,
|
||||
admins, loadingAdmins, newAdminEmail, newMemberRole, addingAdmin, removingAdmin, togglingAdmin,
|
||||
adminSearchQuery, currentAdminPage,
|
||||
handleAddAdmin, handleRemoveAdmin, handleToggleTenantAdmin, handleUpdatePermissions,
|
||||
bootstrapSessions, bootstrapLoading, fetchBootstrapSessions, createBootstrapSession, revokeBootstrapSession,
|
||||
dataRetentionDays, sessionTimeoutHours, handleSaveDataManagement, handleResetDataManagement,
|
||||
showOffboardDialog, offboardConfirmText, offboarding, offboardError, handleOffboard,
|
||||
offboardingInProgress, handleDrainBarrierElapsed,
|
||||
user, getAccessToken,
|
||||
]);
|
||||
|
||||
return (
|
||||
<TenantConfigContext.Provider value={value}>
|
||||
{children}
|
||||
</TenantConfigContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react';
|
||||
import React, { createContext, useContext, useEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||
import { PublicClientApplication, AccountInfo, InteractionStatus, InteractionRequiredAuthError, BrowserAuthError } from '@azure/msal-browser';
|
||||
import { MsalProvider, useMsal, useIsAuthenticated } from '@azure/msal-react';
|
||||
import { msalConfig, loginRequest, apiRequest, activeAuthApp, buildMsalConfig, clientIdForApp } from '@/lib/msalConfig';
|
||||
@@ -531,7 +531,10 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [instance, accounts]);
|
||||
|
||||
const value: AuthContextType = {
|
||||
// Memoized (P6.3): AuthProvider sits above every page — an unmemoized value object
|
||||
// re-rendered every context consumer on each provider render (MSAL account refreshes
|
||||
// included), which cascaded into the settings/dashboard trees.
|
||||
const value: AuthContextType = useMemo(() => ({
|
||||
isAuthenticated,
|
||||
user,
|
||||
hasGlobalScope: (user?.isGlobalAdmin || user?.isGlobalReader) ?? false,
|
||||
@@ -543,7 +546,7 @@ function AuthProviderInternal({ children }: { children: React.ReactNode }) {
|
||||
logout,
|
||||
getAccessToken,
|
||||
refreshUserInfo,
|
||||
};
|
||||
}), [isAuthenticated, user, isLoading, isActivationPending, activationMessage, login, logout, getAccessToken, refreshUserInfo]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
|
||||
|
||||
export type NotificationType = 'error' | 'warning' | 'info' | 'success';
|
||||
|
||||
@@ -119,7 +119,10 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
|
||||
|
||||
const unreadCount = notifications.filter(n => !n.read).length;
|
||||
|
||||
const value: NotificationContextType = {
|
||||
// Memoized (P6.3): every addNotification anywhere in the app re-renders this provider;
|
||||
// without the memo that minted a fresh value object and re-rendered every consumer even
|
||||
// when nothing they read changed.
|
||||
const value: NotificationContextType = useMemo(() => ({
|
||||
notifications,
|
||||
unreadCount,
|
||||
addNotification,
|
||||
@@ -127,7 +130,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode }
|
||||
markAllAsRead,
|
||||
removeNotification,
|
||||
clearAll,
|
||||
};
|
||||
}), [notifications, unreadCount, addNotification, markAsRead, markAllAsRead, removeNotification, clearAll]);
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={value}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import React, { createContext, useContext, useState, useEffect, useMemo } from 'react';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
interface TenantContextType {
|
||||
@@ -53,8 +53,12 @@ export function TenantProvider({ children }: { children: React.ReactNode }) {
|
||||
setTenantId(authTenantId);
|
||||
}
|
||||
|
||||
// Memoized (P6.3): this provider re-renders on every AuthContext emission — without
|
||||
// the memo each of those minted a fresh value object for every tenant consumer.
|
||||
const value = useMemo(() => ({ tenantId, setTenantId }), [tenantId]);
|
||||
|
||||
return (
|
||||
<TenantContext.Provider value={{ tenantId, setTenantId }}>
|
||||
<TenantContext.Provider value={value}>
|
||||
{children}
|
||||
</TenantContext.Provider>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useSyncExternalStore } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useSyncExternalStore } from "react";
|
||||
import {
|
||||
Theme,
|
||||
getServerThemeSnapshot,
|
||||
@@ -30,12 +30,15 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const toggleTheme = useCallback(() => {
|
||||
setStoredTheme(theme === "dark" ? "light" : "dark");
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
// Memoized (P6.3): stable value identity while the theme is unchanged.
|
||||
const value = useMemo(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
||||
<ThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mergeSessionsById } from "../sessionSearchMerge";
|
||||
|
||||
// P6.2: server-search results merge into the loaded dashboard list — existing items
|
||||
// (possibly fresher via SignalR) always win, new matches append, no duplicates.
|
||||
describe("mergeSessionsById", () => {
|
||||
const a = { sessionId: "a", status: "InProgress" };
|
||||
const b = { sessionId: "b", status: "Failed" };
|
||||
const c = { sessionId: "c", status: "Succeeded" };
|
||||
|
||||
it("appends only unknown sessions", () => {
|
||||
const merged = mergeSessionsById([a, b], [b, c]);
|
||||
expect(merged.map((s) => s.sessionId)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
it("keeps the existing copy on sessionId collision", () => {
|
||||
const staleB = { sessionId: "b", status: "InProgress" };
|
||||
const merged = mergeSessionsById([a, b], [staleB]);
|
||||
expect(merged).toHaveLength(2);
|
||||
expect(merged.find((s) => s.sessionId === "b")?.status).toBe("Failed");
|
||||
});
|
||||
|
||||
it("returns the SAME array reference when nothing new arrived (no-op re-render guard)", () => {
|
||||
const existing = [a, b];
|
||||
expect(mergeSessionsById(existing, [])).toBe(existing);
|
||||
expect(mergeSessionsById(existing, [a, b])).toBe(existing);
|
||||
});
|
||||
|
||||
it("handles an empty existing list", () => {
|
||||
expect(mergeSessionsById([], [a, c]).map((s) => s.sessionId)).toEqual(["a", "c"]);
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,14 @@ export const api = {
|
||||
`${API_BASE_URL}/api/sessions/${sessionId}/annotations/${lane}${qs({ tenantId })}`,
|
||||
quickSearch: (q: string) =>
|
||||
`${API_BASE_URL}/api/search/quick${qs({ q })}`,
|
||||
// Server-side free-text search (dashboard search box) — substring across the same
|
||||
// fields the client-side filter uses; JWT-bound tenant. Paginated like list().
|
||||
search: (q: string, opts?: { pageSize?: number; continuation?: string }) =>
|
||||
`${API_BASE_URL}/api/search/sessions${qs({
|
||||
q,
|
||||
pageSize: opts?.pageSize?.toString(),
|
||||
continuation: opts?.continuation,
|
||||
})}`,
|
||||
// NOTE: lives under /api/stats/sessions (NOT /api/sessions/stats) — Azure
|
||||
// Functions' router lets a literal "sessions/stats" be eaten by the sibling
|
||||
// "sessions/{sessionId}" function, which then 404s on "stats" not parsing
|
||||
@@ -109,6 +117,15 @@ export const api = {
|
||||
tenantId: opts?.tenantId,
|
||||
days: opts?.days?.toString(),
|
||||
})}`,
|
||||
// Cross-tenant variant of sessions.search; tenantId narrows to one tenant (filter,
|
||||
// not authorization — the route is GlobalReadOrAdmin).
|
||||
search: (q: string, tenantId?: string, opts?: { pageSize?: number; continuation?: string }) =>
|
||||
`${API_BASE_URL}/api/global/search/sessions${qs({
|
||||
q,
|
||||
tenantId,
|
||||
pageSize: opts?.pageSize?.toString(),
|
||||
continuation: opts?.continuation,
|
||||
})}`,
|
||||
},
|
||||
|
||||
// ── Config ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Merge helper for server-side session search results (Fragilitätsaudit P6.2).
|
||||
*
|
||||
* Search results arrive out-of-order relative to the loaded (newest-first) list and may
|
||||
* overlap with already-loaded or SignalR-updated sessions. The merge keeps every existing
|
||||
* item untouched (the loaded copy may be fresher — live updates mutate it) and appends only
|
||||
* genuinely new sessions; the dashboard's existing sort chain re-orders afterwards.
|
||||
*
|
||||
* Returns the ORIGINAL array reference when nothing new arrived so React state consumers
|
||||
* skip a no-op re-render.
|
||||
*/
|
||||
export function mergeSessionsById<T extends { sessionId: string }>(
|
||||
existing: T[],
|
||||
incoming: T[],
|
||||
): T[] {
|
||||
if (incoming.length === 0) return existing;
|
||||
|
||||
const known = new Set(existing.map((s) => s.sessionId));
|
||||
const fresh = incoming.filter((s) => !known.has(s.sessionId));
|
||||
if (fresh.length === 0) return existing;
|
||||
|
||||
return [...existing, ...fresh];
|
||||
}
|
||||
Reference in New Issue
Block a user