App names and serial numbers travel as query values: app details for names with '/' open

The host decodes %2F to a path separator before routing, so apps/{appName}/analytics|sessions and
their global twins answered 404 for every app whose name contains '/', before any function ran;
a name with a literal escape such as '%20' was decoded a second time. The routes are now
apps/analytics, apps/sessions, global/apps/analytics and global/apps/sessions with ?appName=, and
the unblock route is DELETE devices/block?serialNumber=&tenantId= (serials may contain '/' too).
Function names, policies and tenant scoping stay; req.Query decodes exactly once. Tests run names
with '/', '%', '+', a space and a literal escape through the catalog and the handlers, and the web
builders put the name into the query. Manifest and wire types regenerated (doc text only).
Rollout: backend, then web right after; an app-detail tab on the old bundle 404s until reloaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-10-09 15:17:47 +02:00
parent 7265052936
commit 03a8bf92c9
17 changed files with 326 additions and 68 deletions
@@ -118,7 +118,7 @@ public class AppsAnalyticsFailureRateTests
Assert.False(app.TryGetProperty("trendDelta", out _));
}
// ── /apps/{appName}/analytics ───────────────────────────────────────────
// ── /apps/analytics ─────────────────────────────────────────────────────
private static async Task<JsonElement> BuildAnalyticsAsync(
List<AppInstallSummary> summaries, ISessionRepository? sessionRepo = null)
@@ -137,7 +137,6 @@ public class EndpointPolicyCatalogCompletenessTests
[InlineData("PUT", "/api/rules/gather/rule-1", "rules/gather/{ruleId}")]
[InlineData("DELETE", "/api/tenants/tid-1/admins/user@contoso.com", "tenants/{tenantId}/admins/{adminUpn}")]
[InlineData("PATCH", "/api/tenants/tid-1/admins/user@contoso.com/permissions", "tenants/{tenantId}/admins/{adminUpn}/permissions")]
[InlineData("DELETE", "/api/devices/block/SN123456", "devices/block/{encodedSerialNumber}")]
[InlineData("DELETE", "/api/versions/block/v1.0.*", "versions/block/{encodedPattern}")]
[InlineData("PATCH", "/api/global/session-reports/report-1/note", "global/session-reports/{reportId}/note")]
[InlineData("POST", "/api/rules/analyze/ANALYZE-ID-001/create-from-template", "rules/analyze/{ruleId}/create-from-template")]
@@ -284,8 +283,8 @@ public class EndpointPolicyCatalogCompletenessTests
/// </summary>
[Theory]
[InlineData("GET", "/api/global/apps/list", "global/apps/list")]
[InlineData("GET", "/api/global/apps/Company%20Portal/analytics", "global/apps/{appName}/analytics")]
[InlineData("GET", "/api/global/apps/Company%20Portal/sessions", "global/apps/{appName}/sessions")]
[InlineData("GET", "/api/global/apps/analytics", "global/apps/analytics")]
[InlineData("GET", "/api/global/apps/sessions", "global/apps/sessions")]
public void GlobalAppsRoutes_AreGlobalReadOrAdmin(string method, string path, string expectedTemplate)
{
var entry = EndpointAccessPolicyCatalog.FindPolicy(method, path);
@@ -0,0 +1,226 @@
using System.Net;
using System.Security.Claims;
using System.Text.Json;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Functions.Apps;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// App names and serial numbers are free text and may contain '/', '%' and '+'. They travel as query
/// values: in a path segment the host decodes an escaped slash to a separator before routing (a 404
/// before any function runs), and a catalog placeholder never matches a '/'. The encoded forms below
/// are what the portal's URLSearchParams sends; req.Query decodes them exactly once.
/// </summary>
public class FreeTextNameQueryParamTests
{
private const string TenantId = "00000000-0000-0000-0000-0000000000a7";
public static TheoryData<string, string> Names => new()
{
{ "a/b", "a%2Fb" },
{ "100%", "100%25" },
{ "x+y", "x%2By" },
{ "Company Portal", "Company+Portal" },
// A literal escape sequence survives; the former second decode turned it into "a b".
{ "a%20b", "a%2520b" },
};
// ── Policy catalog ───────────────────────────────────────────────────────
[Theory]
[InlineData("GET", "/api/apps/analytics", "apps/analytics", EndpointPolicy.MemberRead, TenantScoping.None)]
[InlineData("GET", "/api/apps/sessions", "apps/sessions", EndpointPolicy.MemberRead, TenantScoping.None)]
[InlineData("GET", "/api/global/apps/analytics", "global/apps/analytics", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam)]
[InlineData("GET", "/api/global/apps/sessions", "global/apps/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam)]
[InlineData("DELETE", "/api/devices/block", "devices/block", EndpointPolicy.GlobalAdminOnly, TenantScoping.None)]
public void Free_name_routes_are_literal_and_keep_their_policy(
string method, string path, string template, EndpointPolicy policy, TenantScoping scoping)
{
var entry = EndpointAccessPolicyCatalog.FindPolicy(method, path);
Assert.NotNull(entry);
Assert.Equal(template, entry!.RouteTemplate);
Assert.Equal(policy, entry.Policy);
Assert.Equal(scoping, entry.TenantScoping);
}
[Fact]
public void No_route_carries_an_app_name_or_serial_number_in_the_path()
{
var offenders = EndpointAccessPolicyCatalog.Entries
.Where(e => e.RouteTemplate.Contains("{appName}", StringComparison.OrdinalIgnoreCase)
|| e.RouteTemplate.Contains("serialNumber}", StringComparison.OrdinalIgnoreCase))
.Select(e => $"{e.HttpMethod} {e.RouteTemplate}")
.ToList();
Assert.Empty(offenders);
}
// ── Query path: the name reaches the handler unchanged ──────────────────
[Theory]
[MemberData(nameof(Names))]
public async Task App_analytics_matches_the_exact_name(string name, string encoded)
{
var function = new GetAppAnalyticsFunction(
NullLogger<GetAppAnalyticsFunction>.Instance, MetricsWith(name), Sessions(), RegressionTracker());
var req = MemberRequest($"?appName={encoded}&days=30");
var res = await function.Run(req);
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
var root = Json(res);
Assert.Equal(name, root.GetProperty("appName").GetString());
Assert.Equal(1, root.GetProperty("summary").GetProperty("totalInstalls").GetInt32());
}
[Theory]
[MemberData(nameof(Names))]
public async Task App_sessions_match_the_exact_name(string name, string encoded)
{
var function = new GetAppSessionsFunction(NullLogger<GetAppSessionsFunction>.Instance, MetricsWith(name), Sessions());
var req = MemberRequest($"?appName={encoded}&days=30");
var res = await function.Run(req);
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
Assert.Equal(1, Json(res).GetProperty("total").GetInt32());
}
[Theory]
[MemberData(nameof(Names))]
public async Task Global_app_analytics_and_sessions_match_the_exact_name(string name, string encoded)
{
var analytics = new GetGlobalAppAnalyticsFunction(
NullLogger<GetGlobalAppAnalyticsFunction>.Instance, MetricsWith(name), Sessions(), RegressionTracker());
var sessions = new GetGlobalAppSessionsFunction(NullLogger<GetGlobalAppSessionsFunction>.Instance, MetricsWith(name), Sessions());
var query = $"?appName={encoded}&days=30&tenantId={TenantId}";
var analyticsRes = await analytics.Run(EndpointHarness.Request(TenantId, queryString: query).Req);
var sessionsRes = await sessions.Run(EndpointHarness.Request(TenantId, queryString: query).Req);
Assert.Equal(1, Json(analyticsRes).GetProperty("summary").GetProperty("totalInstalls").GetInt32());
Assert.Equal(1, Json(sessionsRes).GetProperty("total").GetInt32());
}
[Fact]
public async Task App_routes_without_a_name_are_a_bad_request()
{
var analytics = new GetAppAnalyticsFunction(
NullLogger<GetAppAnalyticsFunction>.Instance, MetricsWith("x"), Sessions(), RegressionTracker());
var globalSessions = new GetGlobalAppSessionsFunction(NullLogger<GetGlobalAppSessionsFunction>.Instance, MetricsWith("x"), Sessions());
Assert.Equal(HttpStatusCode.BadRequest, (await analytics.Run(MemberRequest("?days=30"))).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest,
(await globalSessions.Run(EndpointHarness.Request(TenantId, queryString: "?appName=%20&days=30").Req)).StatusCode);
}
[Theory]
[MemberData(nameof(Names))]
public async Task Unblock_receives_the_exact_serial_number(string serial, string encoded)
{
var repo = new Mock<IDeviceSecurityRepository>();
repo.Setup(r => r.UnblockDeviceAsync(TenantId, serial)).ReturnsAsync(Array.Empty<string>());
var function = UnblockFunction(repo.Object);
var res = await function.UnblockDevice(
EndpointHarness.Request(TenantId, queryString: $"?serialNumber={encoded}&tenantId={TenantId}").Req);
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
repo.Verify(r => r.UnblockDeviceAsync(TenantId, serial), Times.Once);
}
[Fact]
public async Task Unblock_without_a_serial_number_is_a_bad_request()
{
var repo = new Mock<IDeviceSecurityRepository>();
var function = UnblockFunction(repo.Object);
var res = await function.UnblockDevice(EndpointHarness.Request(TenantId, queryString: $"?tenantId={TenantId}").Req);
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
repo.Verify(r => r.UnblockDeviceAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
// ── Rig ──────────────────────────────────────────────────────────────────
/// <summary>One finished install of <paramref name="name"/>, plus decoys named after its parts.</summary>
private static IMetricsRepository MetricsWith(string name)
{
AppInstallSummary Install(string appName, string sessionId) => new()
{
TenantId = TenantId,
SessionId = sessionId,
AppName = appName,
Status = "Succeeded",
StartedAt = DateTime.UtcNow.AddHours(-2),
DurationSeconds = 30,
};
var summaries = new List<AppInstallSummary> { Install(name, "s-1"), Install("a", "s-2"), Install("b", "s-3"), Install("a b", "s-4") };
var repo = new Mock<IMetricsRepository>();
repo.Setup(r => r.GetAppsDashboardSummariesAsync(It.IsAny<DateTime>(), It.IsAny<string?>()))
.ReturnsAsync(() => summaries.ToList());
return repo.Object;
}
private static ISessionRepository Sessions()
{
var repo = new Mock<ISessionRepository>();
repo.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync((SessionSummary?)null);
return repo.Object;
}
private static IHardwareRejectionNotificationTracker RegressionTracker()
{
var tracker = new Mock<IHardwareRejectionNotificationTracker>();
tracker.Setup(t => t.GetAppVersionRegressionsAsync(It.IsAny<string>())).ReturnsAsync(new List<AppVersionRegressionAlert>());
return tracker.Object;
}
/// <summary>A member request: the JWT tenant comes from the principal the authentication middleware would set.</summary>
private static HttpRequestData MemberRequest(string queryString)
{
var (req, context) = EndpointHarness.Request(TenantId, queryString: queryString);
context.Items["ClaimsPrincipal"] = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("tid", TenantId) }, "test"));
return req;
}
private static DeviceBlockFunction UnblockFunction(IDeviceSecurityRepository securityRepo)
{
var maintenance = new Mock<IMaintenanceRepository>();
maintenance
.Setup(m => m.LogAuditEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()))
.ReturnsAsync(true);
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions())) { CallBase = false };
var opsEvents = new OpsEventService(
Mock.Of<IOpsEventRepository>(), NullLogger<OpsEventService>.Instance,
TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
return new DeviceBlockFunction(
NullLogger<DeviceBlockFunction>.Instance,
new BlockedDeviceService(securityRepo, Mock.Of<ISessionRepository>(), NullLogger<BlockedDeviceService>.Instance),
maintenance.Object,
opsEvents);
}
private static JsonElement Json(HttpResponseData res)
{
res.Body.Position = 0;
using var doc = JsonDocument.Parse(res.Body);
return doc.RootElement.Clone();
}
}
@@ -135,11 +135,14 @@ namespace AutopilotMonitor.Functions.Functions.Admin
}
}
/// <summary>DELETE /api/devices/block/{encodedSerialNumber}?tenantId={tenantId} — unblock a device</summary>
/// <summary>
/// DELETE /api/devices/block?serialNumber={serialNumber}&amp;tenantId={tenantId} — unblock a device. The serial
/// is a query value, never a path segment: serial numbers may contain '/', which the host decodes to a path
/// separator before any route matches.
/// </summary>
[Function("UnblockDevice")]
public async Task<HttpResponseData> UnblockDevice(
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "devices/block/{encodedSerialNumber}")] HttpRequestData req,
string encodedSerialNumber)
[HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "devices/block")] HttpRequestData req)
{
try
{
@@ -150,9 +153,9 @@ namespace AutopilotMonitor.Functions.Functions.Admin
if (string.IsNullOrEmpty(tenantId))
return await req.BadRequestAsync("tenantId query parameter is required");
var serialNumber = Uri.UnescapeDataString(encodedSerialNumber ?? string.Empty);
var serialNumber = req.Query["serialNumber"];
if (string.IsNullOrEmpty(serialNumber))
return await req.BadRequestAsync("serialNumber is required");
return await req.BadRequestAsync("serialNumber query parameter is required");
await _blockedDeviceService.UnblockDeviceAsync(tenantId, serialNumber);
@@ -230,7 +230,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
};
}
// ── /apps/{appName}/analytics ───────────────────────────────────────
// ── /apps/analytics ─────────────────────────────────────────────────
/// <summary>
/// Builds the analytics response body for a single app.
@@ -462,7 +462,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
};
}
// ── /apps/{appName}/sessions ────────────────────────────────────────
// ── /apps/sessions ──────────────────────────────────────────────────
public static async Task<AppSessionsResponse> BuildSessionsResponseAsync(
List<AppInstallSummary> allSummaries,
@@ -9,9 +9,11 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Apps
{
/// <summary>
/// GET /api/apps/{appName}/analytics?days=30
/// GET /api/apps/analytics?appName=...&amp;days=30
/// Per-tenant drill-down for a single app: time series, version breakdown,
/// installer phase breakdown, top failure codes, device-model correlation.
/// The name is a query value, never a path segment: app names may contain '/', which the
/// host decodes to a path separator before any route matches.
/// </summary>
public class GetAppAnalyticsFunction
{
@@ -34,20 +36,19 @@ namespace AutopilotMonitor.Functions.Functions.Apps
[Function("GetAppAnalytics")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "apps/{appName}/analytics")] HttpRequestData req,
string appName)
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "apps/analytics")] HttpRequestData req)
{
try
{
var tenantId = TenantHelper.GetTenantId(req);
var decodedAppName = Uri.UnescapeDataString(appName ?? string.Empty);
if (string.IsNullOrWhiteSpace(decodedAppName))
var query = req.Query;
var appName = query["appName"];
if (string.IsNullOrWhiteSpace(appName))
{
return await req.BadRequestAsync("appName is required");
}
var query = req.Query;
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
if (!AppsAnalyticsHelper.TryParseSourceQueryParam(query["source"], out var source))
{
@@ -57,11 +58,11 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId, days);
// Active duration-regression episodes for this app (fail-soft: empty on error).
var versionRegressions = (await _notificationTracker.GetAppVersionRegressionsAsync(tenantId))
.Where(a => string.Equals(a.AppName, decodedAppName, StringComparison.OrdinalIgnoreCase)
.Where(a => string.Equals(a.AppName, appName, StringComparison.OrdinalIgnoreCase)
&& AppInstallSources.Normalize(a.Source) == source)
.ToList();
var body = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
summaries, _sessionRepo, decodedAppName, source, days, versionRegressions);
summaries, _sessionRepo, appName, source, days, versionRegressions);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(body);
@@ -9,8 +9,9 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Apps
{
/// <summary>
/// GET /api/apps/{appName}/sessions?days=30&amp;status=failed&amp;model=...&amp;version=...&amp;offset=0&amp;limit=50
/// GET /api/apps/sessions?appName=...&amp;days=30&amp;status=failed&amp;model=...&amp;version=...&amp;offset=0&amp;limit=50
/// Returns paginated sessions that (tried to) install a given app for the caller's tenant.
/// The name is a query value for the reason given on <see cref="GetAppAnalyticsFunction"/>.
/// </summary>
public class GetAppSessionsFunction
{
@@ -33,20 +34,19 @@ namespace AutopilotMonitor.Functions.Functions.Apps
[Function("GetAppSessions")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "apps/{appName}/sessions")] HttpRequestData req,
string appName)
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "apps/sessions")] HttpRequestData req)
{
try
{
var tenantId = TenantHelper.GetTenantId(req);
var decodedAppName = Uri.UnescapeDataString(appName ?? string.Empty);
if (string.IsNullOrWhiteSpace(decodedAppName))
var query = req.Query;
var appName = query["appName"];
if (string.IsNullOrWhiteSpace(appName))
{
return await req.BadRequestAsync("appName is required");
}
var query = req.Query;
var days = QueryParams.Int(query["days"], @default: 30, min: 1, max: 365);
if (!AppsAnalyticsHelper.TryParseSourceQueryParam(query["source"], out var source))
{
@@ -63,7 +63,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, tenantId, days);
var body = await AppsAnalyticsHelper.BuildSessionsResponseAsync(
summaries, _sessionRepo, decodedAppName, source, days,
summaries, _sessionRepo, appName, source, days,
statusFilter, modelFilter, versionFilter, offset, limit);
var response = req.CreateResponse(HttpStatusCode.OK);
@@ -9,9 +9,9 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Apps
{
/// <summary>
/// GET /api/global/apps/{appName}/analytics?days=30[&amp;tenantId=GUID]
/// Global Admin variant of <see cref="GetAppAnalyticsFunction"/>.
/// Authorization: GlobalAdminOnly.
/// GET /api/global/apps/analytics?appName=...&amp;days=30[&amp;tenantId=GUID]
/// Global Admin variant of <see cref="GetAppAnalyticsFunction"/> (the name is a query value for the same reason).
/// Authorization: GlobalReadOrAdmin.
/// </summary>
public class GetGlobalAppAnalyticsFunction
{
@@ -34,19 +34,18 @@ namespace AutopilotMonitor.Functions.Functions.Apps
[Function("GetGlobalAppAnalytics")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/apps/{appName}/analytics")] HttpRequestData req,
string appName)
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/apps/analytics")] HttpRequestData req)
{
try
{
var userEmail = TenantHelper.GetUserIdentifier(req);
var decodedAppName = Uri.UnescapeDataString(appName ?? string.Empty);
if (string.IsNullOrWhiteSpace(decodedAppName))
var query = req.Query;
var appName = query["appName"];
if (string.IsNullOrWhiteSpace(appName))
{
return await req.BadRequestAsync("appName is required");
}
var query = req.Query;
var scopedTenantId = query["tenantId"];
if (!AppsAnalyticsHelper.IsValidOptionalTenantIdQueryParam(scopedTenantId))
{
@@ -60,7 +59,7 @@ namespace AutopilotMonitor.Functions.Functions.Apps
_logger.LogInformation(
"Global apps/{App}/analytics requested (user: {User}, tenantId: {TenantId}, days: {Days})",
decodedAppName, userEmail, scopedTenantId ?? "<all>", days);
appName, userEmail, scopedTenantId ?? "<all>", days);
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, scopedTenantId, days);
// Episodes are per-tenant tracker rows; without a tenant scope the aggregated
@@ -68,11 +67,11 @@ namespace AutopilotMonitor.Functions.Functions.Apps
var versionRegressions = string.IsNullOrEmpty(scopedTenantId)
? new List<AppVersionRegressionAlert>()
: (await _notificationTracker.GetAppVersionRegressionsAsync(scopedTenantId!))
.Where(a => string.Equals(a.AppName, decodedAppName, StringComparison.OrdinalIgnoreCase)
.Where(a => string.Equals(a.AppName, appName, StringComparison.OrdinalIgnoreCase)
&& AppInstallSources.Normalize(a.Source) == source)
.ToList();
var body = await AppsAnalyticsHelper.BuildAnalyticsResponseAsync(
summaries, _sessionRepo, decodedAppName, source, days, versionRegressions);
summaries, _sessionRepo, appName, source, days, versionRegressions);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(body);
@@ -9,9 +9,9 @@ using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Apps
{
/// <summary>
/// GET /api/global/apps/{appName}/sessions?days=30&amp;status=failed[&amp;tenantId=GUID][&amp;model=X][&amp;version=Y][&amp;offset=N][&amp;limit=N]
/// Global Admin variant of <see cref="GetAppSessionsFunction"/>.
/// Authorization: GlobalAdminOnly.
/// GET /api/global/apps/sessions?appName=...&amp;days=30&amp;status=failed[&amp;tenantId=GUID][&amp;model=X][&amp;version=Y][&amp;offset=N][&amp;limit=N]
/// Global Admin variant of <see cref="GetAppSessionsFunction"/> (the name is a query value for the same reason).
/// Authorization: GlobalReadOrAdmin.
/// </summary>
public class GetGlobalAppSessionsFunction
{
@@ -34,19 +34,18 @@ namespace AutopilotMonitor.Functions.Functions.Apps
[Function("GetGlobalAppSessions")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/apps/{appName}/sessions")] HttpRequestData req,
string appName)
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/apps/sessions")] HttpRequestData req)
{
try
{
var userEmail = TenantHelper.GetUserIdentifier(req);
var decodedAppName = Uri.UnescapeDataString(appName ?? string.Empty);
if (string.IsNullOrWhiteSpace(decodedAppName))
var query = req.Query;
var appName = query["appName"];
if (string.IsNullOrWhiteSpace(appName))
{
return await req.BadRequestAsync("appName is required");
}
var query = req.Query;
var scopedTenantId = query["tenantId"];
if (!AppsAnalyticsHelper.IsValidOptionalTenantIdQueryParam(scopedTenantId))
{
@@ -68,11 +67,11 @@ namespace AutopilotMonitor.Functions.Functions.Apps
_logger.LogInformation(
"Global apps/{App}/sessions requested (user: {User}, tenantId: {TenantId})",
decodedAppName, userEmail, scopedTenantId ?? "<all>");
appName, userEmail, scopedTenantId ?? "<all>");
var summaries = await AppsAnalyticsHelper.LoadSummariesAsync(_metricsRepo, scopedTenantId, days);
var body = await AppsAnalyticsHelper.BuildSessionsResponseAsync(
summaries, _sessionRepo, decodedAppName, source, days,
summaries, _sessionRepo, appName, source, days,
statusFilter, modelFilter, versionFilter, offset, limit);
var response = req.CreateResponse(HttpStatusCode.OK);
@@ -313,9 +313,11 @@ public static class EndpointAccessPolicyCatalog
// (GA/delegated drill-in) over the SAME route MCP uses — no global duplicate needed.
new("GET", "metrics/device-history", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
new("GET", "metrics/device-journeys", EndpointPolicy.MemberRead),
// Free-text names (app names, serial numbers) are query values, never {placeholders}: the host decodes
// %2F to a path separator before routing, and a placeholder matches no '/' (FreeTextNameQueryParamTests).
new("GET", "apps/list", EndpointPolicy.MemberRead),
new("GET", "apps/{appName}/analytics", EndpointPolicy.MemberRead),
new("GET", "apps/{appName}/sessions", EndpointPolicy.MemberRead),
new("GET", "apps/analytics", EndpointPolicy.MemberRead),
new("GET", "apps/sessions", EndpointPolicy.MemberRead),
new("GET", "metrics/usage", EndpointPolicy.MemberRead),
new("GET", "metrics/sla", EndpointPolicy.MemberRead),
new("GET", "metrics/rule-stats", EndpointPolicy.MemberRead),
@@ -547,8 +549,8 @@ public static class EndpointAccessPolicyCatalog
new("GET", "global/metrics/device-journeys", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/metrics/verdict-calibration", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/apps/list", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/apps/{appName}/analytics", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/apps/{appName}/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/apps/analytics", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/apps/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/metrics/geographic", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/metrics/geographic/sessions", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/metrics/usage", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
@@ -587,7 +589,7 @@ public static class EndpointAccessPolicyCatalog
new("GET", "global/devices/blocked", EndpointPolicy.GlobalReadOrAdmin),
new("GET", "devices/blocked", EndpointPolicy.GlobalReadOrAdmin),
new("POST", "devices/block", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "devices/block/{encodedSerialNumber}", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "devices/block", EndpointPolicy.GlobalAdminOnly),
new("GET", "versions/blocked", EndpointPolicy.GlobalReadOrAdmin),
new("POST", "versions/block", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "versions/block/{encodedPattern}", EndpointPolicy.GlobalAdminOnly),
@@ -446,7 +446,7 @@ export interface AppAnalyticsFailureCode {
sampleMessage: string;
}
/** Response of GET apps/{appName}/analytics and its global variant. */
/** Response of GET apps/analytics?appName= and its global variant. */
export interface AppAnalyticsResponse {
success: boolean;
appName: string;
@@ -641,7 +641,7 @@ export interface AppSessionItem {
installPassCount: number;
}
/** Response of GET apps/{appName}/sessions and its global variant (offset-paged). */
/** Response of GET apps/sessions?appName= and its global variant (offset-paged). */
export interface AppSessionsResponse {
success: boolean;
total: number;
@@ -3,8 +3,8 @@ using System.Collections.Generic;
namespace AutopilotMonitor.Shared.Models
{
// Declaration order == wire order. App Dashboard endpoints (apps/list, apps/{app}/analytics,
// apps/{app}/sessions and their global variants) — lifted from the Functions-local
// Declaration order == wire order. App Dashboard endpoints (apps/list, apps/analytics,
// apps/sessions and their global variants) — lifted from the Functions-local
// anonymous builders in AppsAnalyticsHelper so the manifest exports them.
/// <summary>
@@ -54,7 +54,7 @@ namespace AutopilotMonitor.Shared.Models
public DateTime LastSeenAt { get; set; }
}
/// <summary>Response of <c>GET apps/{appName}/analytics</c> and its global variant.</summary>
/// <summary>Response of <c>GET apps/analytics?appName=</c> and its global variant.</summary>
public class AppAnalyticsResponse : IApiResponse
{
public bool Success { get; set; }
@@ -151,7 +151,7 @@ namespace AutopilotMonitor.Shared.Models
public double LiftVsBaseline { get; set; }
}
/// <summary>Response of <c>GET apps/{appName}/sessions</c> and its global variant (offset-paged).</summary>
/// <summary>Response of <c>GET apps/sessions?appName=</c> and its global variant (offset-paged).</summary>
public class AppSessionsResponse : IApiResponse
{
public bool Success { get; set; }
@@ -61,6 +61,17 @@ describe("api path ids are percent-encoded", () => {
expect(api.sessions.watch(HOSTILE, "t")).toBe(`https://test.example/api/sessions/${ENCODED}/watch?tenantId=t`);
});
it("free-text names (app names, serials) travel as query values", async () => {
const { api } = await apiPromise;
const unblock = new URL(api.devices.unblock("SN/1+2%", "t"));
expect(unblock.pathname).toBe("/api/devices/block");
expect(unblock.searchParams.get("serialNumber")).toBe("SN/1+2%");
expect(unblock.searchParams.get("tenantId")).toBe("t");
const analytics = new URL(api.apps.globalAnalytics("a/b", 30));
expect(analytics.pathname).toBe("/api/global/apps/analytics");
expect(analytics.searchParams.get("appName")).toBe("a/b");
});
it("no path builder lets an id escape its segment", async () => {
const { api } = await apiPromise;
type Builder = (...args: string[]) => string;
@@ -84,7 +84,7 @@ describe("scopedApi routing", () => {
const t = scopedApi.appSessions(tenantMode, "My App", 7, "failed", 20, 10);
const g = scopedApi.appSessions(override, "My App", 7, "failed", 20, 10);
for (const url of [t, g]) {
expect(url).toContain("My%20App");
expect(url).toContain("appName=My+App");
expect(url).toContain("days=7");
expect(url).toContain("status=failed");
expect(url).toContain("offset=20");
@@ -93,6 +93,19 @@ describe("scopedApi routing", () => {
expect(g).toContain(`tenantId=${OTHER}`);
});
it("per-app URLs carry the name as a query value, never in the path", () => {
// The host decodes %2F to a path separator before routing, so a name in the path 404s.
for (const name of ["a/b", "100%", "x+y", "a%20b"]) {
for (const sel of [tenantMode, override]) {
for (const url of [scopedApi.appAnalytics(sel, name, 30), scopedApi.appSessions(sel, name, 30, "all", 0, 10)]) {
const parsed = new URL(url);
expect(parsed.pathname).toMatch(/^\/api\/(global\/)?apps\/(analytics|sessions)$/);
expect(parsed.searchParams.get("appName")).toBe(name);
}
}
}
});
it("per-app URLs carry a non-Intune install channel and leave the Intune default out", () => {
for (const sel of [tenantMode, override]) {
expect(scopedApi.appAnalytics(sel, "7-Zip", 30, { source: "realmjoin" })).toContain("source=realmjoin");
+10 -5
View File
@@ -249,8 +249,9 @@ export const api = {
blocked: (tenantId: string) =>
`${API_BASE_URL}/api/devices/blocked${qs({ tenantId })}`,
block: () => `${API_BASE_URL}/api/devices/block`,
// Serials may contain '/': a query value, never a path segment (the host decodes %2F before routing).
unblock: (serialNumber: string, tenantId: string) =>
`${API_BASE_URL}/api/devices/block/${encodeURIComponent(serialNumber)}${qs({ tenantId })}`,
`${API_BASE_URL}/api/devices/block${qs({ serialNumber, tenantId })}`,
allBlocked: () => `${API_BASE_URL}/api/global/devices/blocked`,
},
@@ -372,12 +373,14 @@ export const api = {
},
// ── Apps Dashboard ────────────────────────────────────────────────────────
// App names may contain '/': the name is a query value, never a path segment (the host decodes
// %2F before routing).
apps: {
list: (tenantId: string, days: number) =>
`${API_BASE_URL}/api/apps/list${qs({ tenantId, days: String(days) })}`,
// source = install channel ("realmjoin"); the Intune default is left out of the URL.
analytics: (tenantId: string, appName: string, days: number, opts?: { source?: string }) =>
`${API_BASE_URL}/api/apps/${encodeURIComponent(appName)}/analytics${qs({ tenantId, days: String(days), source: appInstallSourceParam(opts?.source) })}`,
`${API_BASE_URL}/api/apps/analytics${qs({ appName, tenantId, days: String(days), source: appInstallSourceParam(opts?.source) })}`,
// model/version live in an options object: as trailing positional strings they sat
// adjacent to other optional strings, so a forgotten argument silently shifted the
// next one into its place (compiles fine, queries the wrong thing).
@@ -390,7 +393,8 @@ export const api = {
limit = 50,
opts?: { model?: string; version?: string; source?: string }
) =>
`${API_BASE_URL}/api/apps/${encodeURIComponent(appName)}/sessions${qs({
`${API_BASE_URL}/api/apps/sessions${qs({
appName,
tenantId,
days: String(days),
status,
@@ -407,7 +411,7 @@ export const api = {
globalList: (days: number, tenantId?: string) =>
`${API_BASE_URL}/api/global/apps/list${qs({ days: String(days), tenantId })}`,
globalAnalytics: (appName: string, days: number, tenantId?: string, opts?: { source?: string }) =>
`${API_BASE_URL}/api/global/apps/${encodeURIComponent(appName)}/analytics${qs({ days: String(days), tenantId, source: appInstallSourceParam(opts?.source) })}`,
`${API_BASE_URL}/api/global/apps/analytics${qs({ appName, days: String(days), tenantId, source: appInstallSourceParam(opts?.source) })}`,
globalSessions: (
appName: string,
days: number,
@@ -417,7 +421,8 @@ export const api = {
tenantId?: string,
opts?: { model?: string; version?: string; source?: string }
) =>
`${API_BASE_URL}/api/global/apps/${encodeURIComponent(appName)}/sessions${qs({
`${API_BASE_URL}/api/global/apps/sessions${qs({
appName,
days: String(days),
status,
offset: String(offset),
@@ -2826,7 +2826,7 @@
},
"AppAnalyticsResponse": {
"kind": "object",
"doc": "Response of GET apps/{appName}/analytics and its global variant.",
"doc": "Response of GET apps/analytics?appName= and its global variant.",
"fields": [
{
"name": "success",
@@ -3774,7 +3774,7 @@
},
"AppSessionsResponse": {
"kind": "object",
"doc": "Response of GET apps/{appName}/sessions and its global variant (offset-paged).",
"doc": "Response of GET apps/sessions?appName= and its global variant (offset-paged).",
"fields": [
{
"name": "success",
@@ -446,7 +446,7 @@ export interface AppAnalyticsFailureCode {
sampleMessage: string;
}
/** Response of GET apps/{appName}/analytics and its global variant. */
/** Response of GET apps/analytics?appName= and its global variant. */
export interface AppAnalyticsResponse {
success: boolean;
appName: string;
@@ -641,7 +641,7 @@ export interface AppSessionItem {
installPassCount: number;
}
/** Response of GET apps/{appName}/sessions and its global variant (offset-paged). */
/** Response of GET apps/sessions?appName= and its global variant (offset-paged). */
export interface AppSessionsResponse {
success: boolean;
total: number;