diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/ProgressPortalFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/ProgressPortalFunctionTests.cs index e721df45..13e6e270 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/ProgressPortalFunctionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/ProgressPortalFunctionTests.cs @@ -1,7 +1,10 @@ using AutopilotMonitor.Functions.Functions.Progress; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Shared.Pagination; +using Moq; using Xunit; namespace AutopilotMonitor.Functions.Tests; @@ -65,6 +68,94 @@ public class ProgressPortalFunctionTests Assert.Equal(TenantScoping.QueryParam, entry.TenantScoping); } + // ── ResolveSessionAsync: exact index lookups first, substring page only for members/GA ── + + private const string TenantId = "11111111-1111-1111-1111-111111111111"; + + private static Mock Repo( + string? bySerial = null, string? byDeviceName = null, SessionSummary? session = null, params SessionSummary[] newestPage) + { + var repo = new Mock(MockBehavior.Strict); + repo.Setup(r => r.FindNewestSessionIdBySerialAsync(TenantId, It.IsAny())).ReturnsAsync(bySerial); + repo.Setup(r => r.FindNewestSessionIdByDeviceNameAsync(TenantId, It.IsAny())).ReturnsAsync(byDeviceName); + if (session != null) + repo.Setup(r => r.GetSessionAsync(TenantId, session.SessionId)).ReturnsAsync(session); + repo.Setup(r => r.GetSessionsPageAsync(TenantId, null, ProgressPortalFunction.LookupPageSize, null)) + .ReturnsAsync(new RawPage(newestPage, null)); + return repo; + } + + [Fact] + public async Task Resolve_exact_serial_hit_is_served_from_the_index_not_the_newest_page() + { + // THE FINDING: the newest-100 page missed any enrollment older than that horizon and + // answered "Device Not Found". The index lookup has no horizon; the page is never read. + var old = Session(ageMinutes: 100_000); + var repo = Repo(bySerial: old.SessionId, session: old); + + var match = await ProgressPortalFunction.ResolveSessionAsync(repo.Object, TenantId, " pf3xkq7 ", allowSubstring: false); + + Assert.Same(old, match); + repo.Verify(r => r.FindNewestSessionIdBySerialAsync(TenantId, "pf3xkq7"), Times.Once); + repo.Verify(r => r.GetSessionsPageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Resolve_falls_back_to_exact_device_name_when_the_serial_misses() + { + var byName = Session(); + var repo = Repo(byDeviceName: byName.SessionId, session: byName); + + var match = await ProgressPortalFunction.ResolveSessionAsync(repo.Object, TenantId, Device, allowSubstring: false); + + Assert.Same(byName, match); + repo.Verify(r => r.FindNewestSessionIdByDeviceNameAsync(TenantId, Device), Times.Once); + } + + [Fact] + public async Task Resolve_roleless_caller_gets_no_substring_fallback() + { + var repo = Repo(newestPage: Session(serial: "PF3XKQ7-EXTRA")); + + var match = await ProgressPortalFunction.ResolveSessionAsync(repo.Object, TenantId, "PF3XKQ7", allowSubstring: false); + + Assert.Null(match); + repo.Verify(r => r.GetSessionsPageAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Resolve_member_falls_back_to_substring_over_the_newest_page() + { + var fuzzy = Session(serial: "PF3XKQ7-EXTRA"); + var repo = Repo(newestPage: fuzzy); + + var match = await ProgressPortalFunction.ResolveSessionAsync(repo.Object, TenantId, "3XKQ", allowSubstring: true); + + Assert.Same(fuzzy, match); + } + + [Fact] + public async Task Resolve_exact_hit_beats_a_newer_substring_match_for_members() + { + // A helpdesk substring query used to land on whichever newer device contained the + // fragment inside the page; the exact device wins now even when it is older. + var exact = Session(ageMinutes: 5_000); + var newerFuzzy = Session(serial: "PF3XKQ7-NEWER", ageMinutes: 0); + var repo = Repo(bySerial: exact.SessionId, session: exact, newestPage: newerFuzzy); + + var match = await ProgressPortalFunction.ResolveSessionAsync(repo.Object, TenantId, Serial, allowSubstring: true); + + Assert.Same(exact, match); + } + + [Fact] + public async Task Resolve_blank_search_is_null_without_any_storage_call() + { + var repo = new Mock(MockBehavior.Strict); + + Assert.Null(await ProgressPortalFunction.ResolveSessionAsync(repo.Object, TenantId, " ", allowSubstring: true)); + } + // ── FindBestMatch: roleless callers need the EXACT serial / device name ── [Theory] diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Progress/ProgressPortalFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Progress/ProgressPortalFunction.cs index 6d2193d3..2e43544a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Progress/ProgressPortalFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Progress/ProgressPortalFunction.cs @@ -25,7 +25,10 @@ public class ProgressPortalFunction private readonly ILogger _logger; private readonly ISessionRepository _sessionRepo; - /// How many latest sessions the lookup scans — the portal's pre-existing horizon. + /// + /// Page size of the substring fallback (members/GA only): the newest sessions the fuzzy + /// match scans. Exact serial / device-name lookups are index queries without a horizon. + /// internal const int LookupPageSize = 100; public ProgressPortalFunction( @@ -76,8 +79,7 @@ public class ProgressPortalFunction requestCtx.UserPrincipalName, tenantId); } - var page = await _sessionRepo.GetSessionsPageAsync(tenantId, days: null, pageSize: LookupPageSize, continuation: null); - var match = FindBestMatch(page.Items, search, allowSubstring: requestCtx.IsTenantMemberOrGlobalAdmin()); + var match = await ResolveSessionAsync(_sessionRepo, tenantId, search, allowSubstring: requestCtx.IsTenantMemberOrGlobalAdmin()); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new ProgressLookupSessionResponse @@ -103,6 +105,38 @@ public class ProgressPortalFunction } } + /// + /// Resolves the search term to at most one session. Exact serial, then exact device name — + /// both as index queries over the whole tenant partition, so a device whose enrollment is + /// older than the newest hundred sessions is still found (the previous newest-N page silently + /// answered "not found" for it). Only members/GA fall through to the substring match over the + /// newest sessions; roleless callers get exact-or-nothing (the + /// knowledge proof). An exact hit always wins over a fuzzy one — a substring may otherwise + /// resolve to a different, newer device. + /// + internal static async Task ResolveSessionAsync( + ISessionRepository sessionRepo, string tenantId, string search, bool allowSubstring) + { + var query = search.Trim(); + if (query.Length == 0) + return null; + + var sessionId = await sessionRepo.FindNewestSessionIdBySerialAsync(tenantId, query) + ?? await sessionRepo.FindNewestSessionIdByDeviceNameAsync(tenantId, query); + if (sessionId != null) + { + var session = await sessionRepo.GetSessionAsync(tenantId, sessionId); + if (session != null) + return session; + } + + if (!allowSubstring) + return null; + + var page = await sessionRepo.GetSessionsPageAsync(tenantId, days: null, pageSize: LookupPageSize, continuation: null); + return FindBestMatch(page.Items, query, allowSubstring: true); + } + /// /// Picks the newest session matching the search term. Roleless callers (allowSubstring=false) /// must present the EXACT serial number or device name (trimmed, case-insensitive) — anything