fix(backend): progress-portal lookup resolves exact serial/device name via index queries

The lookup scanned only the newest 100 SessionsIndex rows, so any enrollment older than that horizon answered 'Device Not Found' and a member substring search could land on a newer device. Exact serial and device name now run as server-side eq filters over the tenant partition (stored form or upper-case), newest session wins, then a point-read; the substring page remains the members/GA-only fallback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-01 23:37:42 +02:00
parent fb49dc9ad2
commit d8ef8b11b0
2 changed files with 128 additions and 3 deletions
@@ -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<ISessionRepository> Repo(
string? bySerial = null, string? byDeviceName = null, SessionSummary? session = null, params SessionSummary[] newestPage)
{
var repo = new Mock<ISessionRepository>(MockBehavior.Strict);
repo.Setup(r => r.FindNewestSessionIdBySerialAsync(TenantId, It.IsAny<string>())).ReturnsAsync(bySerial);
repo.Setup(r => r.FindNewestSessionIdByDeviceNameAsync(TenantId, It.IsAny<string>())).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<SessionSummary>(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<string>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>()), 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<string>(), It.IsAny<int?>(), It.IsAny<int>(), It.IsAny<string?>()), 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<ISessionRepository>(MockBehavior.Strict);
Assert.Null(await ProgressPortalFunction.ResolveSessionAsync(repo.Object, TenantId, " ", allowSubstring: true));
}
// ── FindBestMatch: roleless callers need the EXACT serial / device name ──
[Theory]
@@ -25,7 +25,10 @@ public class ProgressPortalFunction
private readonly ILogger<ProgressPortalFunction> _logger;
private readonly ISessionRepository _sessionRepo;
/// <summary>How many latest sessions the lookup scans — the portal's pre-existing horizon.</summary>
/// <summary>
/// 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.
/// </summary>
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
}
}
/// <summary>
/// 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 <see cref="LookupPageSize"/> 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.
/// </summary>
internal static async Task<SessionSummary?> 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);
}
/// <summary>
/// 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