diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/HardwareRejectionNotificationTrackerDedupTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/HardwareRejectionNotificationTrackerDedupTests.cs new file mode 100644 index 00000000..51ec2023 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/HardwareRejectionNotificationTrackerDedupTests.cs @@ -0,0 +1,304 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using AutopilotMonitor.Functions.DataAccess.TableStorage; +using AutopilotMonitor.Functions.Services; +using Azure; +using Azure.Data.Tables; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Behavioural coverage for — the dedup +/// store behind the two distress-driven bell notifications (hardware rejection per model, +/// TPM-PSS incompatibility per serial). +/// pins the RowKey shape; this file pins what the tracker DOES with those keys. +/// +/// CORRECTNESS GUARD: the "one bell ever" promise rests entirely on the 409 branch of +/// AddEntityAsync — a rewrite to Upsert or a get-then-add would silently start ringing +/// the bell on every distress report (a device with an incompatible TPM retries forever, so +/// "every report" means a notification storm, not a second bell). +/// +/// The retention sweep is covered here too because DeleteOlderThanAsync prunes BOTH key +/// spaces from the shared table: pruning a tpmpss| row re-arms that device's bell. +/// +public class HardwareRejectionNotificationTrackerDedupTests +{ + private const string TenantId = "77777777-7777-7777-7777-777777777777"; + private const string OtherTenantId = "88888888-8888-8888-8888-888888888888"; + private const string Serial = "S4SQ8685"; + + // ── TPM PSS: first bell, then never again ────────────────────────────────────── + + [Fact] + public async Task TpmPss_FirstReport_RegistersRowAndReturnsTrue() + { + var harness = new Harness(); + + var isFirst = await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial); + + Assert.True(isFirst); + var entity = Assert.Single(harness.Store.Values); + Assert.Equal(TenantId.ToLowerInvariant(), entity.PartitionKey); + Assert.Equal("tpmpss|s4sq8685", entity.RowKey); + // Serialization guard: the row must carry the fields the cleanup sweep and any future + // operator query rely on — losing FirstNotifiedAt would make the row immortal. + Assert.Equal(TenantId, entity.GetString("TenantId")); + Assert.Equal(Serial, entity.GetString("SerialNumber")); + Assert.NotNull(entity.GetDateTime("FirstNotifiedAt")); + } + + [Fact] + public async Task TpmPss_SecondReport_SameSerial_ReturnsFalse() + { + var harness = new Harness(); + + Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial)); + Assert.False(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial)); + Assert.Single(harness.Store); + } + + [Theory] + [InlineData("s4sq8685")] + [InlineData(" S4SQ8685 ")] + [InlineData("S4Sq8685")] + public async Task TpmPss_SecondReport_CasingOrWhitespaceVariant_StillDeduped(string variant) + { + var harness = new Harness(); + + Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial)); + Assert.False(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, variant)); + } + + [Fact] + public async Task TpmPss_DifferentSerial_OrDifferentTenant_RingsAgain() + { + var harness = new Harness(); + + Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial)); + Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, "OTHER123")); + Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(OtherTenantId, Serial)); + Assert.Equal(3, harness.Store.Count); + } + + [Theory] + [InlineData("", Serial)] + [InlineData(" ", Serial)] + [InlineData(TenantId, "")] + [InlineData(TenantId, " ")] + public async Task TpmPss_BlankInput_ReturnsFalse_WithoutTouchingTheTable(string tenantId, string serial) + { + var harness = new Harness(); + + Assert.False(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(tenantId, serial)); + Assert.Empty(harness.Store); + harness.Table.Verify( + c => c.AddEntityAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task TpmPss_UnexpectedTableFailure_ReturnsFalse() + { + // Fail-soft in the SUPPRESSING direction: the write may or may not have landed, so the + // caller must not fire a bell it might fire again on the next report. + var harness = new Harness { AddFailure = new RequestFailedException(503, "ServerBusy") }; + + Assert.False(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial)); + } + + // ── Hardware rejection: same contract, disjoint key space ────────────────────── + + [Fact] + public async Task HardwareRejection_SecondReport_SameModel_ReturnsFalse() + { + var harness = new Harness(); + + Assert.True(await harness.Sut.TryRegisterFirstNotificationAsync(TenantId, "Lenovo", "ThinkPad X1")); + Assert.False(await harness.Sut.TryRegisterFirstNotificationAsync(TenantId, "LENOVO", " thinkpad x1 ")); + Assert.Single(harness.Store); + } + + [Fact] + public async Task HardwareRejection_AndTpmPss_DoNotDedupEachOtherInTheSharedTable() + { + var harness = new Harness(); + + Assert.True(await harness.Sut.TryRegisterFirstNotificationAsync(TenantId, "Lenovo", Serial)); + Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial)); + Assert.Equal(2, harness.Store.Count); + } + + // ── Retention sweep ─────────────────────────────────────────────────────────── + + [Fact] + public async Task DeleteOlderThanAsync_PrunesTpmPssRows_AndReArmsThatDevicesBell() + { + var harness = new Harness(); + Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial)); + harness.AgeRows(TimeSpan.FromDays(31)); + + var deleted = await harness.Sut.DeleteOlderThanAsync(DateTime.UtcNow.AddDays(-30)); + + Assert.Equal(1, deleted); + Assert.Empty(harness.Store); + // A device whose TPM was never fixed keeps reporting; after the retention window it is + // worth one more bell rather than permanent silence. + Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial)); + } + + [Fact] + public async Task DeleteOlderThanAsync_KeepsRowsInsideTheWindow_DedupSurvives() + { + var harness = new Harness(); + Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial)); + Assert.True(await harness.Sut.TryRegisterFirstNotificationAsync(TenantId, "Lenovo", "ThinkPad X1")); + + var deleted = await harness.Sut.DeleteOlderThanAsync(DateTime.UtcNow.AddDays(-30)); + + Assert.Equal(0, deleted); + Assert.Equal(2, harness.Store.Count); + Assert.False(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial)); + Assert.False(await harness.Sut.TryRegisterFirstNotificationAsync(TenantId, "Lenovo", "ThinkPad X1")); + } + + [Fact] + public async Task DeleteOlderThanAsync_FiltersOnFirstNotifiedAt_NotTimestamp() + { + // Rows are insert-once, so FirstNotifiedAt is the creation time. Timestamp would look + // equivalent but is rewritten by a table restore/copy, which would silently re-arm every + // bell in the tenant at once. + var harness = new Harness(); + var cutoff = new DateTime(2026, 7, 25, 12, 0, 0, DateTimeKind.Utc); + + await harness.Sut.DeleteOlderThanAsync(cutoff); + + var filter = Assert.Single(harness.QueryFilters); + Assert.Equal("FirstNotifiedAt lt datetime'2026-07-25T12:00:00Z'", filter); + } + + [Fact] + public async Task DeleteOlderThanAsync_ContinuesAfterASingleRowFailure() + { + var harness = new Harness(); + Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, Serial)); + Assert.True(await harness.Sut.TryRegisterFirstTpmPssNotificationAsync(TenantId, "OTHER123")); + harness.AgeRows(TimeSpan.FromDays(31)); + harness.FailDeleteForRowKeys.Add("tpmpss|s4sq8685"); + + var deleted = await harness.Sut.DeleteOlderThanAsync(DateTime.UtcNow.AddDays(-30)); + + Assert.Equal(1, deleted); + Assert.Equal("tpmpss|s4sq8685", Assert.Single(harness.Store.Values).RowKey); + } + + [Fact] + public async Task DeleteOlderThanAsync_QueryFailure_ReturnsZero() + { + var harness = new Harness { QueryFailure = new RequestFailedException(500, "Boom") }; + + Assert.Equal(0, await harness.Sut.DeleteOlderThanAsync(DateTime.UtcNow.AddDays(-30))); + } + + // ── Harness ─────────────────────────────────────────────────────────────────── + + private sealed class Harness + { + private static readonly Regex CutoffFilter = + new(@"^FirstNotifiedAt lt datetime'(?[^']+)'$", RegexOptions.Compiled); + + public TableHardwareRejectionNotificationTracker Sut { get; } + public Mock Table { get; } + public Dictionary<(string Pk, string Rk), TableEntity> Store { get; } = new(); + public List QueryFilters { get; } = new(); + public HashSet FailDeleteForRowKeys { get; } = new(); + + /// Non-409 failure injected into AddEntityAsync (fail-soft path). + public Exception? AddFailure { get; init; } + + /// Failure injected into QueryAsync enumeration (sweep fail-soft path). + public Exception? QueryFailure { get; init; } + + public Harness() + { + Table = new Mock(); + + Table.Setup(c => c.AddEntityAsync(It.IsAny(), It.IsAny())) + .Returns((e, _) => + { + if (AddFailure != null) throw AddFailure; + if (Store.ContainsKey((e.PartitionKey, e.RowKey))) + throw new RequestFailedException(409, "Conflict", "EntityAlreadyExists", null); + Store[(e.PartitionKey, e.RowKey)] = e; + return Task.FromResult(new Mock().Object); + }); + + Table.Setup(c => c.QueryAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns, CancellationToken>((filter, _, _, _) => + { + if (QueryFailure != null) throw QueryFailure; + QueryFilters.Add(filter); + var rows = Store.Values.Where(e => MatchesCutoff(filter, e)).ToList(); + return AsyncPageable.FromPages(new[] + { + Page.FromValues(rows, null, new Mock().Object), + }); + }); + + Table.Setup(c => c.DeleteEntityAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns((pk, rk, _, _) => + { + if (FailDeleteForRowKeys.Contains(rk)) + throw new RequestFailedException(500, "DeleteFailed"); + Store.Remove((pk, rk)); + return Task.FromResult(new Mock().Object); + }); + + var serviceClient = new Mock(); + serviceClient.Setup(s => s.GetTableClient(It.IsAny())).Returns(Table.Object); + + Sut = new TableHardwareRejectionNotificationTracker( + new TableStorageService(serviceClient.Object, NullLogger.Instance), + NullLogger.Instance); + } + + /// Backdates every stored row so a retention sweep sees it as expired. + public void AgeRows(TimeSpan age) + { + foreach (var entity in Store.Values) + entity["FirstNotifiedAt"] = DateTime.UtcNow - age; + } + + /// + /// Minimal server-side emulation of the OData filter the tracker builds. An unrecognised + /// filter matches nothing, so a rewrite that no longer selects expired rows fails loudly + /// here instead of leaving dead rows in production. + /// + private static bool MatchesCutoff(string filter, TableEntity entity) + { + var match = CutoffFilter.Match(filter ?? string.Empty); + if (!match.Success) return false; + + var cutoff = DateTime.Parse( + match.Groups["cutoff"].Value, + null, + System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal); + var firstNotified = entity.GetDateTime("FirstNotifiedAt"); + return firstNotified.HasValue && firstNotified.Value.ToUniversalTime() < cutoff; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableHardwareRejectionNotificationTracker.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableHardwareRejectionNotificationTracker.cs index 643e9d2a..56b84174 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableHardwareRejectionNotificationTracker.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableHardwareRejectionNotificationTracker.cs @@ -114,9 +114,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage try { // Rows are insert-once (AddEntityAsync, never updated), so FirstNotifiedAt == creation time. - // Pruning here resets the lifetime dedup for a given model: if the same model is rejected - // again after the cutoff, the bell fires once more — acceptable, since the portal only - // surfaces recent rejections anyway. + // Pruning here resets the lifetime dedup for BOTH key spaces in this table: a hardware + // model rejected again after the cutoff rings once more, and so does a TPM-PSS device + // that reports again ("tpmpss|{serial}" rows are pruned by the same sweep). Acceptable + // in both cases — the portal only surfaces recent rejections anyway, and a device whose + // TPM was never fixed is worth surfacing again after a month of silence. var filter = $"FirstNotifiedAt lt datetime'{cutoffUtc:yyyy-MM-ddTHH:mm:ss}Z'"; var query = _table.QueryAsync(filter: filter, select: new[] { "PartitionKey", "RowKey" }); diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IHardwareRejectionNotificationTracker.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IHardwareRejectionNotificationTracker.cs index 104b6a13..2b476036 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IHardwareRejectionNotificationTracker.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IHardwareRejectionNotificationTracker.cs @@ -30,7 +30,11 @@ namespace AutopilotMonitor.Shared.DataAccess /// /// Retention cleanup: deletes tracker rows whose FirstNotifiedAt is older than - /// . Pruning re-arms the dedup for that model. Returns rows deleted. + /// . Both key spaces live in one table, so pruning re-arms the + /// dedup for BOTH subjects: a hardware model rejected again after the cutoff rings once more, + /// and so does a TPM-PSS-incompatible device that reports again. That is intended — a bell + /// that can never ring a second time would go silent on a device the tenant never fixed. + /// Returns rows deleted. /// Task DeleteOlderThanAsync(DateTime cutoffUtc); } diff --git a/src/Web/autopilot-monitor-web/app/about/page.tsx b/src/Web/autopilot-monitor-web/app/about/page.tsx index a79fc7f7..d13b94fc 100644 --- a/src/Web/autopilot-monitor-web/app/about/page.tsx +++ b/src/Web/autopilot-monitor-web/app/about/page.tsx @@ -27,9 +27,9 @@ const FEATURES = [ { title: "Diagnostics Collection", description: - "Collect agent logs, IME logs, agent state, and device information as a ZIP bundle at the end of an enrollment — then download it from the session view without touching the device.", + "Collect agent logs, IME logs, agent state, and device information as a ZIP bundle — automatically at the end of an enrollment, or on demand while one is still running — then download it from the session view without touching the device.", color: "green", - bullets: ["Configurable upload: off, always, or on failure", "Agent, IME & device information bundle", "Configurable additional log paths"], + bullets: ["Configurable upload: off, always, or on failure", "On-demand collection from a running enrollment", "Agent, IME & device information bundle", "Configurable additional log paths"], }, { title: "Detailed Event Timeline", diff --git a/src/Web/autopilot-monitor-web/app/privacy/page.tsx b/src/Web/autopilot-monitor-web/app/privacy/page.tsx index 1cbf85f1..b5a70b42 100644 --- a/src/Web/autopilot-monitor-web/app/privacy/page.tsx +++ b/src/Web/autopilot-monitor-web/app/privacy/page.tsx @@ -148,8 +148,17 @@ export default function PrivacyPage() {

Diagnostics upload is off by default. When enabled, the default destination is{" "} your own Azure storage account — the package never reaches our infrastructure. Hosted upload - exists as an alternative but is opt-in only and requires an explicit administrator action behind a clearly - marked disclosure that data leaves your tenant. It is never enabled silently. + exists as an alternative but is opt-in only and requires an explicit administrator action — in Settings, or + in the one-step dialog offered on a session — always behind a clearly marked disclosure that data leaves your + tenant. It is never enabled silently. +

+

+ Once upload is configured, an Admin or Operator in your own tenant can also request a + package on demand from an enrollment that is still running, instead of waiting for it to finish. It collects + exactly what the automatic package collects — the configured paths, nothing wider — the device only acts on + the request when the agent next checks in, and both the request and its outcome appear as events on that + session's timeline. Viewers and Progress-Portal members cannot trigger it, and it does nothing on a + tenant that has no upload destination configured.

How Long We Keep Data

diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/CollectLogsButton.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/CollectLogsButton.tsx index a416f294..01da96b4 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/CollectLogsButton.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/CollectLogsButton.tsx @@ -270,9 +270,23 @@ export default function CollectLogsButton({ {" "}hosted storage (managed by AutopilotMonitor) with mode {" "}On failure, then collects the logs from this device right away.

+ {/* Data-boundary disclosure. This dialog is the SECOND opt-in path into hosted + upload (the first is Settings → Diagnostics Package), and the privacy page and + trust FAQ both promise that hosted upload is only ever enabled behind a clearly + marked "data leaves your tenant" disclosure — this block is that promise here. */} +
+

+ Uploaded packages leave your own Azure tenant boundary.{" "} + They are stored in the AutopilotMonitor backend's Azure Storage (Germany West Central), + isolated per tenant, and each upload uses a fresh blob-scoped, write-only token. +

+

+ Packages are removed automatically according to your Data Retention Days setting. +

+

You can change the destination (e.g. to your own Azure Blob Storage) or turn uploads off again at any - time under Settings → Diagnostics Package. + time under Settings → Diagnostics Package, which also lists exactly which paths are collected.