mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
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:
@@ -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]
|
||||
|
||||
+37
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user